This commit is contained in:
RT15548
2025-12-19 02:19:10 +08:00
commit 593fce3c8c
45 changed files with 34004 additions and 0 deletions

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# LittleWhiteBox
SillyTavern 扩展插件 - 小白X
## 📁 目录结构
```
LittleWhiteBox/
├── manifest.json # 插件配置清单
├── index.js # 主入口文件
├── settings.html # 设置页面模板
├── style.css # 全局样式
├── modules/ # 功能模块目录
│ ├── streaming-generation.js # 流式生成
│ ├── dynamic-prompt.js # 动态提示词
│ ├── immersive-mode.js # 沉浸模式
│ ├── message-preview.js # 消息预览
│ ├── wallhaven-background.js # 壁纸背景
│ ├── button-collapse.js # 按钮折叠
│ ├── control-audio.js # 音频控制
│ ├── script-assistant.js # 脚本助手
│ │
│ ├── variables/ # 变量系统
│ │ ├── variables-core.js
│ │ └── variables-panel.js
│ │
│ ├── template-editor/ # 模板编辑器
│ │ ├── template-editor.js
│ │ └── template-editor.html
│ │
│ ├── scheduled-tasks/ # 定时任务
│ │ ├── scheduled-tasks.js
│ │ ├── scheduled-tasks.html
│ │ └── embedded-tasks.html
│ │
│ ├── story-summary/ # 故事摘要
│ │ ├── story-summary.js
│ │ └── story-summary.html
│ │
│ └── story-outline/ # 故事大纲
│ ├── story-outline.js
│ ├── story-outline-prompt.js
│ └── story-outline.html
├── bridges/ # 外部桥接模块
│ ├── worldbook-bridge.js # 世界书桥接
│ ├── call-generate-service.js # 生成服务调用
│ └── wrapper-iframe.js # iframe 包装器
├── ui/ # UI 模板
│ └── character-updater-menus.html
└── docs/ # 文档
├── script-docs.md # 脚本文档
├── LICENSE.md # 许可证
├── COPYRIGHT # 版权信息
└── NOTICE # 声明
```
## 📝 模块组织规则
- **单文件模块**:直接放在 `modules/` 目录下
- **多文件模块**:创建子目录,包含相关的 JS、HTML 等文件
- **桥接模块**:与外部系统交互的独立模块放在 `bridges/`
- **避免使用 `index.js`**:每个模块文件直接命名,不使用 `index.js`
## 🔄 版本历史
- v2.2.2 - 目录结构重构2025-12-08
## 📄 许可证
详见 `docs/LICENSE.md`

File diff suppressed because it is too large Load Diff

899
bridges/worldbook-bridge.js Normal file
View File

@@ -0,0 +1,899 @@
// @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";
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) {
try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookResult', id: requestId, result }, '*'); } catch {}
}
sendError(target, requestId, err, fallbackCode = 'API_ERROR', details = null) {
const e = this.normalizeError(err, fallbackCode, details);
try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookError', id: requestId, error: e }, '*'); } catch {}
}
postEvent(event, payload) {
try { window?.postMessage({ source: SOURCE_TAG, type: 'worldbookEvent', event, payload }, '*'); } 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 ctx = getContext();
const tags = ctx.tags || [];
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);
} catch (err) {
try { xbLog.error('worldbookBridge', `worldbookRequest failed id=${id} action=${String(action || '')}`, err); } catch {}
self.sendError(event.source || window, id, err);
}
} catch {}
};
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 (_) {}
}

105
bridges/wrapper-iframe.js Normal file
View File

@@ -0,0 +1,105 @@
(function(){
function defineCallGenerate(){
function sanitizeOptions(options){
try{
return JSON.parse(JSON.stringify(options,function(k,v){return(typeof v==='function')?undefined:v}))
}catch(_){
try{
const seen=new WeakSet();
const clone=(val)=>{
if(val===null||val===undefined)return val;
const t=typeof val;
if(t==='function')return undefined;
if(t!=='object')return val;
if(seen.has(val))return undefined;
seen.add(val);
if(Array.isArray(val)){
const arr=[];for(let i=0;i<val.length;i++){const v=clone(val[i]);if(v!==undefined)arr.push(v)}return arr;
}
const proto=Object.getPrototypeOf(val);
if(proto!==Object.prototype&&proto!==null)return undefined;
const out={};
for(const k in val){if(Object.prototype.hasOwnProperty.call(val,k)){const 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{
function post(m){try{parent.postMessage(m,'*')}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){
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){}
}
try{defineCallGenerate()}catch(e){}
})();
(function(){
function applyAvatarCss(urls){
try{
const 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')){
const 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)}
`;
const style=document.createElement('style');
style.id='xb-avatar-style';
style.textContent=css;
document.head.appendChild(style);
}
}catch(_){}
}
function requestAvatars(){
try{parent.postMessage({type:'getAvatars'},'*')}catch(_){}
}
function onMessage(e){
const 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(_){}
})();

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 };

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;
}
}

73
docs/COPYRIGHT Normal file
View File

@@ -0,0 +1,73 @@
LittleWhiteBox (小白X) - Copyright and Attribution Requirements
================================================================
Copyright 2025 biex
This software is licensed under the Apache License 2.0
with additional custom attribution requirements.
MANDATORY ATTRIBUTION REQUIREMENTS
==================================
1. AUTHOR ATTRIBUTION
- The original author "biex" MUST be prominently credited in any derivative work
- This credit must appear in:
* Software user interface (visible to end users)
* Documentation and README files
* Source code headers
* About/Credits sections
* Any promotional or marketing materials
2. PROJECT ATTRIBUTION
- The project name "LittleWhiteBox" and "小白X" must be credited
- Required attribution format: "Based on LittleWhiteBox by biex"
- Project URL must be included: https://github.com/RT15548/LittleWhiteBox
3. SOURCE CODE DISCLOSURE
- Any modification, enhancement, or derivative work MUST be open source
- Source code must be publicly accessible under the same license terms
- All changes must be clearly documented and attributed
4. COMMERCIAL USE
- Commercial use is permitted under the Apache License 2.0 terms
- Attribution requirements still apply for commercial use
- No additional permission required for commercial use
5. TRADEMARK PROTECTION
- "LittleWhiteBox" and "小白X" are trademarks of the original author
- Derivative works may not use these names without explicit permission
- Alternative naming must clearly indicate the derivative nature
VIOLATION CONSEQUENCES
=====================
Any violation of these attribution requirements will result in:
- Immediate termination of the license grant
- Legal action for copyright infringement
- Demand for removal of infringing content
COMPLIANCE EXAMPLES
==================
✅ CORRECT Attribution Examples:
- "Powered by LittleWhiteBox by biex"
- "Based on LittleWhiteBox (https://github.com/RT15548/LittleWhiteBox) by biex"
- "Enhanced version of LittleWhiteBox by biex - Original: [repository URL]"
❌ INCORRECT Examples:
- Using the code without any attribution
- Claiming original authorship
- Using "LittleWhiteBox" name for derivative works
- Commercial use without permission
- Closed-source modifications
CONTACT INFORMATION
==================
For licensing inquiries or attribution questions:
- Repository: https://github.com/RT15548/LittleWhiteBox
- Author: biex
- License: Apache-2.0 WITH Custom-Attribution-Requirements
This copyright notice and attribution requirements must be included in all
copies or substantial portions of the software.

33
docs/LICENSE.md Normal file
View File

@@ -0,0 +1,33 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2025 biex
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
ADDITIONAL TERMS:
In addition to the terms of the Apache License 2.0, the following
attribution requirement applies to any use, modification, or distribution
of this software:
ATTRIBUTION REQUIREMENT:
If you reference, modify, or distribute any file from this project,
you must include attribution to the original author "biex" in your
project documentation, README, or credits section.
Simple attribution format: "Based on LittleWhiteBox by biex"
For the complete Apache License 2.0 text, see:
http://www.apache.org/licenses/LICENSE-2.0

95
docs/NOTICE Normal file
View File

@@ -0,0 +1,95 @@
LittleWhiteBox (小白X) - Third-Party Notices and Attributions
================================================================
This software contains code and dependencies from various third-party sources.
The following notices and attributions are required by their respective licenses.
PRIMARY SOFTWARE
================
LittleWhiteBox (小白X)
Copyright 2025 biex
Licensed under Apache-2.0 WITH Custom-Attribution-Requirements
Repository: https://github.com/RT15548/LittleWhiteBox
RUNTIME DEPENDENCIES
====================
This extension is designed to work with SillyTavern and relies on the following
SillyTavern modules and APIs:
1. SillyTavern Core Framework
- Copyright: SillyTavern Contributors
- License: AGPL-3.0
- Repository: https://github.com/SillyTavern/SillyTavern
2. SillyTavern Extensions API
- Used modules: extensions.js, script.js
- Provides: Extension framework, settings management, event system
3. SillyTavern Slash Commands
- Used modules: slash-commands.js, SlashCommandParser.js
- Provides: Command execution framework
4. SillyTavern UI Components
- Used modules: popup.js, utils.js
- Provides: User interface components and utilities
BROWSER APIS AND STANDARDS
==========================
This software uses standard web browser APIs:
- DOM API (Document Object Model)
- Fetch API for HTTP requests
- PostMessage API for iframe communication
- Local Storage API for data persistence
- Mutation Observer API for DOM monitoring
JAVASCRIPT LIBRARIES
====================
The software may interact with the following JavaScript libraries
that are part of the SillyTavern environment:
1. jQuery
- Copyright: jQuery Foundation and contributors
- License: MIT License
- Used for: DOM manipulation and event handling
2. Toastr (if available)
- Copyright: CodeSeven
- License: MIT License
- Used for: Notification display
DEVELOPMENT TOOLS
=================
The following tools were used in development (not distributed):
- Visual Studio Code
- Git version control
- Various Node.js development tools
ATTRIBUTION REQUIREMENTS
========================
When distributing this software or derivative works, you must:
1. Include this NOTICE file
2. Maintain all copyright notices in source code
3. Provide attribution to the original author "biex"
4. Include a link to the original repository
5. Comply with Apache-2.0 license requirements
6. Follow the custom attribution requirements in LICENSE.md
DISCLAIMER
==========
This software is provided "AS IS" without warranty of any kind.
The author disclaims all warranties, express or implied, including
but not limited to the warranties of merchantability, fitness for
a particular purpose, and non-infringement.
For complete license terms, see LICENSE.md
For attribution requirements, see COPYRIGHT
Last updated: 2025-01-14

1718
docs/script-docs.md Normal file

File diff suppressed because it is too large Load Diff

766
index.js Normal file
View File

@@ -0,0 +1,766 @@
// ═══════════════════════════════════════════════════════════════════════════
// 导入
// ═══════════════════════════════════════════════════════════════════════════
import { extension_settings, getContext } from "../../../extensions.js";
import { saveSettingsDebounced, eventSource, event_types, getRequestHeaders } from "../../../../script.js";
import { EXT_ID, EXT_NAME, extensionFolderPath } from "./core/constants.js";
import { executeSlashCommand } from "./core/slash-command.js";
import { EventCenter } from "./core/event-manager.js";
import { initTasks } from "./modules/scheduled-tasks/scheduled-tasks.js";
import { initScriptAssistant } from "./modules/script-assistant.js";
import { initMessagePreview, addHistoryButtonsDebounced } from "./modules/message-preview.js";
import { initImmersiveMode } from "./modules/immersive-mode.js";
import { initTemplateEditor, templateSettings } from "./modules/template-editor/template-editor.js";
import { initWallhavenBackground } from "./modules/wallhaven-background.js";
import { initFourthWall, fourthWallCleanup } from "./modules/fourth-wall/fourth-wall.js";
import { initButtonCollapse } from "./modules/button-collapse.js";
import { initVariablesPanel, getVariablesPanelInstance, cleanupVariablesPanel } from "./modules/variables/variables-panel.js";
import { initStreamingGeneration } from "./modules/streaming-generation.js";
import { initVariablesCore, cleanupVariablesCore } from "./modules/variables/variables-core.js";
import { initControlAudio } from "./modules/control-audio.js";
import {
initRenderer,
cleanupRenderer,
processExistingMessages,
processMessageById,
invalidateAll,
clearBlobCaches,
renderHtmlInIframe,
shrinkRenderedWindowFull
} from "./modules/iframe-renderer.js";
import { initVarCommands, cleanupVarCommands } from "./modules/variables/var-commands.js";
import { initVareventEditor, cleanupVareventEditor } from "./modules/variables/varevent-editor.js";
import { initNovelDraw, cleanupNovelDraw } from "./modules/novel-draw/novel-draw.js";
import "./modules/story-summary/story-summary.js";
import "./modules/story-outline/story-outline.js";
// ═══════════════════════════════════════════════════════════════════════════
// 常量与默认设置
// ═══════════════════════════════════════════════════════════════════════════
const MODULE_NAME = "xiaobaix-memory";
extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
enabled: true,
sandboxMode: false,
recorded: { enabled: true },
templateEditor: { enabled: true, characterBindings: {} },
tasks: { enabled: true, globalTasks: [], processedMessages: [], character_allowed_tasks: [] },
scriptAssistant: { enabled: false },
preview: { enabled: false },
wallhaven: { enabled: false },
immersive: { enabled: false },
fourthWall: { enabled: true },
audio: { enabled: true },
variablesPanel: { enabled: false },
variablesCore: { enabled: true },
storySummary: { enabled: true },
storyOutline: { enabled: true },
novelDraw: { enabled: false },
useBlob: false,
wrapperIframe: true,
renderEnabled: true,
maxRenderedMessages: 5,
};
const settings = extension_settings[EXT_ID];
if (settings.dynamicPrompt && !settings.fourthWall) settings.fourthWall = settings.dynamicPrompt;
// ═══════════════════════════════════════════════════════════════════════════
// 废弃数据清理
// ═══════════════════════════════════════════════════════════════════════════
const DEPRECATED_KEYS = [
'characterUpdater',
'promptSections',
'promptPresets',
'relationshipGuidelines'
];
function cleanupDeprecatedData() {
const s = extension_settings[EXT_ID];
if (!s) return;
let cleaned = false;
for (const key of DEPRECATED_KEYS) {
if (key in s) {
delete s[key];
cleaned = true;
console.log(`[LittleWhiteBox] 清理废弃数据: ${key}`);
}
}
if (cleaned) {
saveSettingsDebounced();
console.log('[LittleWhiteBox] 废弃数据清理完成');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 状态变量
// ═══════════════════════════════════════════════════════════════════════════
let isXiaobaixEnabled = settings.enabled;
let moduleCleanupFunctions = new Map();
let updateCheckPerformed = false;
window.isXiaobaixEnabled = isXiaobaixEnabled;
window.testLittleWhiteBoxUpdate = async () => {
updateCheckPerformed = false;
await performExtensionUpdateCheck();
};
window.testUpdateUI = () => {
updateExtensionHeaderWithUpdateNotice();
};
window.testRemoveUpdateUI = () => {
removeAllUpdateNotices();
};
// ═══════════════════════════════════════════════════════════════════════════
// 更新检查
// ═══════════════════════════════════════════════════════════════════════════
async function checkLittleWhiteBoxUpdate() {
try {
const timestamp = Date.now();
const localRes = await fetch(`${extensionFolderPath}/manifest.json?t=${timestamp}`, { cache: 'no-cache' });
if (!localRes.ok) return null;
const localManifest = await localRes.json();
const localVersion = localManifest.version;
const remoteRes = await fetch(`https://api.github.com/repos/RT15548/LittleWhiteBox/contents/manifest.json?t=${timestamp}`, { cache: 'no-cache' });
if (!remoteRes.ok) return null;
const remoteData = await remoteRes.json();
const remoteManifest = JSON.parse(atob(remoteData.content));
const remoteVersion = remoteManifest.version;
return localVersion !== remoteVersion ? { isUpToDate: false, localVersion, remoteVersion } : { isUpToDate: true, localVersion, remoteVersion };
} catch (e) {
return null;
}
}
async function updateLittleWhiteBoxExtension() {
try {
const response = await fetch('/api/extensions/update', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ extensionName: 'LittleWhiteBox', global: true }),
});
if (!response.ok) {
const text = await response.text();
toastr.error(text || response.statusText, '小白X更新失败', { timeOut: 5000 });
return false;
}
const data = await response.json();
const message = data.isUpToDate ? '小白X已是最新版本' : `小白X已更新`;
const title = data.isUpToDate ? '' : '请刷新页面以应用更新';
toastr.success(message, title);
return true;
} catch (error) {
toastr.error('更新过程中发生错误', '小白X更新失败');
return false;
}
}
function updateExtensionHeaderWithUpdateNotice() {
addUpdateTextNotice();
addUpdateDownloadButton();
}
function addUpdateTextNotice() {
const selectors = [
'.inline-drawer-toggle.inline-drawer-header b',
'.inline-drawer-header b',
'.littlewhitebox .inline-drawer-header b',
'div[class*="inline-drawer"] b'
];
let headerElement = null;
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const element of elements) {
if (element.textContent && element.textContent.includes('小白X')) {
headerElement = element;
break;
}
}
if (headerElement) break;
}
if (!headerElement) {
setTimeout(() => addUpdateTextNotice(), 1000);
return;
}
if (headerElement.querySelector('.littlewhitebox-update-text')) return;
const updateTextSmall = document.createElement('small');
updateTextSmall.className = 'littlewhitebox-update-text';
updateTextSmall.textContent = '(有可用更新)';
headerElement.appendChild(updateTextSmall);
}
function addUpdateDownloadButton() {
const sectionDividers = document.querySelectorAll('.section-divider');
let totalSwitchDivider = null;
for (const divider of sectionDividers) {
if (divider.textContent && divider.textContent.includes('总开关')) {
totalSwitchDivider = divider;
break;
}
}
if (!totalSwitchDivider) {
setTimeout(() => addUpdateDownloadButton(), 1000);
return;
}
if (document.querySelector('#littlewhitebox-update-extension')) return;
const updateButton = document.createElement('div');
updateButton.id = 'littlewhitebox-update-extension';
updateButton.className = 'menu_button fa-solid fa-cloud-arrow-down interactable has-update';
updateButton.title = '下载并安装小白x的更新';
updateButton.tabIndex = 0;
try {
totalSwitchDivider.style.display = 'flex';
totalSwitchDivider.style.alignItems = 'center';
totalSwitchDivider.style.justifyContent = 'flex-start';
} catch (e) {}
totalSwitchDivider.appendChild(updateButton);
try {
if (window.setupUpdateButtonInSettings) {
window.setupUpdateButtonInSettings();
}
} catch (e) {}
}
function removeAllUpdateNotices() {
const textNotice = document.querySelector('.littlewhitebox-update-text');
const downloadButton = document.querySelector('#littlewhitebox-update-extension');
if (textNotice) textNotice.remove();
if (downloadButton) downloadButton.remove();
}
async function performExtensionUpdateCheck() {
if (updateCheckPerformed) return;
updateCheckPerformed = true;
try {
const versionData = await checkLittleWhiteBoxUpdate();
if (versionData && versionData.isUpToDate === false) {
updateExtensionHeaderWithUpdateNotice();
}
} catch (error) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// 模块清理注册
// ═══════════════════════════════════════════════════════════════════════════
function registerModuleCleanup(moduleName, cleanupFunction) {
moduleCleanupFunctions.set(moduleName, cleanupFunction);
}
function removeSkeletonStyles() {
try {
document.querySelectorAll('.xiaobaix-skel').forEach(el => {
try { el.remove(); } catch (e) {}
});
document.getElementById('xiaobaix-skeleton-style')?.remove();
} catch (e) {}
}
function cleanupAllResources() {
try {
EventCenter.cleanupAll();
} catch (e) {}
try { window.xbDebugPanelClose?.(); } catch (e) {}
moduleCleanupFunctions.forEach((cleanupFn) => {
try {
cleanupFn();
} catch (e) {}
});
moduleCleanupFunctions.clear();
try {
cleanupRenderer();
} catch (e) {}
document.querySelectorAll('.memory-button, .mes_history_preview').forEach(btn => btn.remove());
document.querySelectorAll('#message_preview_btn').forEach(btn => {
if (btn instanceof HTMLElement) {
btn.style.display = 'none';
}
});
document.getElementById('xiaobaix-hide-code')?.remove();
document.body.classList.remove('xiaobaix-active');
document.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
delete pre.dataset.xbFinal;
pre.style.display = '';
delete pre.dataset.xiaobaixBound;
});
removeSkeletonStyles();
}
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
async function waitForElement(selector, root = document, timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const element = root.querySelector(selector);
if (element) return element;
await new Promise(r => setTimeout(r, 100));
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// 设置控件禁用/启用
// ═══════════════════════════════════════════════════════════════════════════
function toggleSettingsControls(enabled) {
const controls = [
'xiaobaix_sandbox', 'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled',
'xiaobaix_script_assistant', 'scheduled_tasks_enabled', 'xiaobaix_template_enabled',
'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category',
'wallhaven_purity', 'wallhaven_opacity',
'xiaobaix_immersive_enabled', 'xiaobaix_fourth_wall_enabled',
'xiaobaix_audio_enabled', 'xiaobaix_variables_panel_enabled',
'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'Wrapperiframe', 'xiaobaix_render_enabled',
'xiaobaix_max_rendered', 'xiaobaix_story_outline_enabled', 'xiaobaix_story_summary_enabled',
'xiaobaix_novel_draw_enabled', 'xiaobaix_novel_draw_open_settings'
];
controls.forEach(id => {
$(`#${id}`).prop('disabled', !enabled).closest('.flex-container').toggleClass('disabled-control', !enabled);
});
const styleId = 'xiaobaix-disabled-style';
if (!enabled && !document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `.disabled-control, .disabled-control * { opacity: 0.4 !important; pointer-events: none !important; cursor: not-allowed !important; }`;
document.head.appendChild(style);
} else if (enabled) {
document.getElementById(styleId)?.remove();
}
}
function ensureHideCodeStyle(enable) {
const id = 'xiaobaix-hide-code';
const old = document.getElementById(id);
if (!enable) {
old?.remove();
return;
}
if (old) return;
const hideCodeStyle = document.createElement('style');
hideCodeStyle.id = id;
hideCodeStyle.textContent = `
.xiaobaix-active .mes_text pre { display: none !important; }
.xiaobaix-active .mes_text pre.xb-show { display: block !important; }
`;
document.head.appendChild(hideCodeStyle);
}
function setActiveClass(enable) {
document.body.classList.toggle('xiaobaix-active', !!enable);
}
// ═══════════════════════════════════════════════════════════════════════════
// 功能总开关切换
// ═══════════════════════════════════════════════════════════════════════════
function toggleAllFeatures(enabled) {
if (enabled) {
if (settings.renderEnabled !== false) {
ensureHideCodeStyle(true);
setActiveClass(true);
}
toggleSettingsControls(true);
try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) {}
saveSettingsDebounced();
initRenderer();
try { initVarCommands(); } catch (e) {}
try { initVareventEditor(); } catch (e) {}
const moduleInits = [
{ condition: extension_settings[EXT_ID].tasks?.enabled, init: initTasks },
{ condition: extension_settings[EXT_ID].scriptAssistant?.enabled, init: initScriptAssistant },
{ condition: extension_settings[EXT_ID].immersive?.enabled, init: initImmersiveMode },
{ condition: extension_settings[EXT_ID].templateEditor?.enabled, init: initTemplateEditor },
{ condition: extension_settings[EXT_ID].wallhaven?.enabled, init: initWallhavenBackground },
{ condition: extension_settings[EXT_ID].fourthWall?.enabled, init: initFourthWall },
{ condition: extension_settings[EXT_ID].variablesPanel?.enabled, init: initVariablesPanel },
{ condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore },
{ condition: extension_settings[EXT_ID].novelDraw?.enabled, init: initNovelDraw },
{ condition: true, init: initStreamingGeneration },
{ condition: true, init: initButtonCollapse }
];
moduleInits.forEach(({ condition, init }) => {
if (condition) init();
});
if (extension_settings[EXT_ID].preview?.enabled || extension_settings[EXT_ID].recorded?.enabled) {
setTimeout(initMessagePreview, 200);
}
if (extension_settings[EXT_ID].scriptAssistant?.enabled && window.injectScriptDocs)
setTimeout(() => window.injectScriptDocs(), 400);
if (extension_settings[EXT_ID].preview?.enabled)
setTimeout(() => { document.querySelectorAll('#message_preview_btn').forEach(btn => btn.style.display = ''); }, 500);
if (extension_settings[EXT_ID].recorded?.enabled)
setTimeout(() => addHistoryButtonsDebounced(), 600);
try {
if (isXiaobaixEnabled && settings.wrapperIframe && !document.getElementById('xb-callgen'))
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-callgen', type: 'module', src: `${extensionFolderPath}/bridges/call-generate-service.js` }));
} catch (e) {}
try {
if (isXiaobaixEnabled && !document.getElementById('xb-worldbook'))
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-worldbook', type: 'module', src: `${extensionFolderPath}/bridges/worldbook-bridge.js` }));
} catch (e) {}
document.dispatchEvent(new CustomEvent('xiaobaixEnabledChanged', { detail: { enabled: true } }));
$(document).trigger('xiaobaix:enabled:toggle', [true]);
} else {
try { window.XB_captureAndStoreStates && window.XB_captureAndStoreStates(); } catch (e) {}
cleanupAllResources();
if (window.messagePreviewCleanup) try { window.messagePreviewCleanup(); } catch (e) {}
if (window.fourthWallCleanup) try { window.fourthWallCleanup(); } catch (e) {}
if (window.buttonCollapseCleanup) try { window.buttonCollapseCleanup(); } catch (e) {}
try { cleanupVariablesPanel(); } catch (e) {}
try { cleanupVariablesCore(); } catch (e) {}
try { cleanupVarCommands(); } catch (e) {}
try { cleanupVareventEditor(); } catch (e) {}
try { cleanupNovelDraw(); } catch (e) {}
try { clearBlobCaches(); } catch (e) {}
toggleSettingsControls(false);
document.getElementById('xiaobaix-hide-code')?.remove();
setActiveClass(false);
document.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
delete pre.dataset.xbFinal;
pre.style.display = '';
delete pre.dataset.xiaobaixBound;
});
window.removeScriptDocs?.();
try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) {}
try { window.cleanupCallGenerateHostBridge && window.cleanupCallGenerateHostBridge(); document.getElementById('xb-callgen')?.remove(); } catch (e) {}
document.dispatchEvent(new CustomEvent('xiaobaixEnabledChanged', { detail: { enabled: false } }));
$(document).trigger('xiaobaix:enabled:toggle', [false]);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 设置面板初始化
// ═══════════════════════════════════════════════════════════════════════════
async function setupSettings() {
try {
const settingsContainer = await waitForElement("#extensions_settings");
if (!settingsContainer) return;
const response = await fetch(`${extensionFolderPath}/settings.html`);
const settingsHtml = await response.text();
$(settingsContainer).append(settingsHtml);
setupDebugButtonInSettings();
$("#xiaobaix_enabled").prop("checked", settings.enabled).on("change", function () {
const wasEnabled = settings.enabled;
settings.enabled = $(this).prop("checked");
isXiaobaixEnabled = settings.enabled;
window.isXiaobaixEnabled = isXiaobaixEnabled;
saveSettingsDebounced();
if (settings.enabled !== wasEnabled) {
toggleAllFeatures(settings.enabled);
}
});
if (!settings.enabled) toggleSettingsControls(false);
$("#xiaobaix_sandbox").prop("checked", settings.sandboxMode).on("change", function () {
if (!isXiaobaixEnabled) return;
settings.sandboxMode = $(this).prop("checked");
saveSettingsDebounced();
});
const moduleConfigs = [
{ id: 'xiaobaix_recorded_enabled', key: 'recorded' },
{ id: 'xiaobaix_immersive_enabled', key: 'immersive', init: initImmersiveMode },
{ id: 'xiaobaix_preview_enabled', key: 'preview', init: initMessagePreview },
{ id: 'xiaobaix_script_assistant', key: 'scriptAssistant', init: initScriptAssistant },
{ id: 'scheduled_tasks_enabled', key: 'tasks', init: initTasks },
{ id: 'xiaobaix_template_enabled', key: 'templateEditor', init: initTemplateEditor },
{ id: 'wallhaven_enabled', key: 'wallhaven', init: initWallhavenBackground },
{ id: 'xiaobaix_fourth_wall_enabled', key: 'fourthWall', init: initFourthWall },
{ id: 'xiaobaix_variables_panel_enabled', key: 'variablesPanel', init: initVariablesPanel },
{ id: 'xiaobaix_variables_core_enabled', key: 'variablesCore', init: initVariablesCore },
{ id: 'xiaobaix_story_summary_enabled', key: 'storySummary' },
{ id: 'xiaobaix_story_outline_enabled', key: 'storyOutline' },
{ id: 'xiaobaix_novel_draw_enabled', key: 'novelDraw', init: initNovelDraw }
];
moduleConfigs.forEach(({ id, key, init }) => {
$(`#${id}`).prop("checked", settings[key]?.enabled || false).on("change", function () {
if (!isXiaobaixEnabled) return;
const enabled = $(this).prop('checked');
if (!enabled && key === 'fourthWall') {
try { fourthWallCleanup(); } catch (e) {}
}
if (!enabled && key === 'novelDraw') {
try { cleanupNovelDraw(); } catch (e) {}
}
settings[key] = extension_settings[EXT_ID][key] || {};
settings[key].enabled = enabled;
extension_settings[EXT_ID][key] = settings[key];
saveSettingsDebounced();
if (moduleCleanupFunctions.has(key)) {
moduleCleanupFunctions.get(key)();
moduleCleanupFunctions.delete(key);
}
if (enabled && init) init();
if (key === 'storySummary') {
$(document).trigger('xiaobaix:storySummary:toggle', [enabled]);
}
if (key === 'storyOutline') {
$(document).trigger('xiaobaix:storyOutline:toggle', [enabled]);
}
});
});
$("#xiaobaix_novel_draw_open_settings").on("click", function () {
if (!isXiaobaixEnabled) return;
if (settings.novelDraw?.enabled && window.xiaobaixNovelDraw?.openSettings) {
window.xiaobaixNovelDraw.openSettings();
}
});
$("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", function () {
if (!isXiaobaixEnabled) return;
settings.useBlob = $(this).prop("checked");
saveSettingsDebounced();
});
$("#Wrapperiframe").prop("checked", !!settings.wrapperIframe).on("change", function () {
if (!isXiaobaixEnabled) return;
settings.wrapperIframe = $(this).prop("checked");
saveSettingsDebounced();
try {
settings.wrapperIframe
? (!document.getElementById('xb-callgen') && document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-callgen', type: 'module', src: `${extensionFolderPath}/bridges/call-generate-service.js` })))
: (window.cleanupCallGenerateHostBridge && window.cleanupCallGenerateHostBridge(), document.getElementById('xb-callgen')?.remove());
} catch (e) {}
});
$("#xiaobaix_render_enabled").prop("checked", settings.renderEnabled !== false).on("change", function () {
if (!isXiaobaixEnabled) return;
const wasEnabled = settings.renderEnabled !== false;
settings.renderEnabled = $(this).prop("checked");
saveSettingsDebounced();
if (!settings.renderEnabled && wasEnabled) {
document.getElementById('xiaobaix-hide-code')?.remove();
document.body.classList.remove('xiaobaix-active');
invalidateAll();
} else if (settings.renderEnabled && !wasEnabled) {
ensureHideCodeStyle(true);
document.body.classList.add('xiaobaix-active');
setTimeout(() => processExistingMessages(), 100);
}
});
const normalizeMaxRendered = (raw) => {
let v = parseInt(raw, 10);
if (!Number.isFinite(v) || v < 1) v = 1;
if (v > 9999) v = 9999;
return v;
};
$("#xiaobaix_max_rendered")
.val(Number.isFinite(settings.maxRenderedMessages) ? settings.maxRenderedMessages : 5)
.on("input change", function () {
if (!isXiaobaixEnabled) return;
const v = normalizeMaxRendered($(this).val());
$(this).val(v);
settings.maxRenderedMessages = v;
saveSettingsDebounced();
try { shrinkRenderedWindowFull(); } catch (e) {}
});
$(document).off('click.xbreset', '#xiaobaix_reset_btn').on('click.xbreset', '#xiaobaix_reset_btn', function (e) {
e.preventDefault();
e.stopPropagation();
const MAP = {
recorded: 'xiaobaix_recorded_enabled',
immersive: 'xiaobaix_immersive_enabled',
preview: 'xiaobaix_preview_enabled',
scriptAssistant: 'xiaobaix_script_assistant',
tasks: 'scheduled_tasks_enabled',
templateEditor: 'xiaobaix_template_enabled',
wallhaven: 'wallhaven_enabled',
fourthWall: 'xiaobaix_fourth_wall_enabled',
variablesPanel: 'xiaobaix_variables_panel_enabled',
variablesCore: 'xiaobaix_variables_core_enabled',
novelDraw: 'xiaobaix_novel_draw_enabled'
};
const ON = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore'];
const OFF = ['recorded', 'preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'novelDraw'];
function setChecked(id, val) {
const el = document.getElementById(id);
if (el) {
el.checked = !!val;
try { $(el).trigger('change'); } catch {}
}
}
ON.forEach(k => setChecked(MAP[k], true));
OFF.forEach(k => setChecked(MAP[k], false));
setChecked('xiaobaix_sandbox', false);
setChecked('xiaobaix_use_blob', false);
setChecked('Wrapperiframe', true);
try { saveSettingsDebounced(); } catch (e) {}
});
} catch (err) {}
}
function setupDebugButtonInSettings() {
try {
if (document.getElementById('xiaobaix-debug-btn')) return;
const enableCheckbox = document.getElementById('xiaobaix_enabled');
if (!enableCheckbox) {
setTimeout(setupDebugButtonInSettings, 800);
return;
}
const row = enableCheckbox.closest('.flex-container') || enableCheckbox.parentElement;
if (!row) return;
const btn = document.createElement('div');
btn.id = 'xiaobaix-debug-btn';
btn.className = 'menu_button';
btn.title = '切换调试监控';
btn.tabIndex = 0;
btn.style.marginLeft = 'auto';
btn.style.whiteSpace = 'nowrap';
btn.innerHTML = '<span class="dbg-light"></span><span>监控</span>';
const onActivate = async () => {
try {
const mod = await import('./modules/debug-panel/debug-panel.js');
if (mod?.toggleDebugPanel) await mod.toggleDebugPanel();
} catch (e) {}
};
btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onActivate(); });
btn.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); onActivate(); }
});
row.appendChild(btn);
} catch (e) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// 菜单标签切换
// ═══════════════════════════════════════════════════════════════════════════
function setupMenuTabs() {
$(document).on('click', '.menu-tab', function () {
const targetId = $(this).attr('data-target');
$('.menu-tab').removeClass('active');
$('.settings-section').hide();
$(this).addClass('active');
$('.' + targetId).show();
});
setTimeout(() => {
$('.js-memory').show();
$('.task, .instructions').hide();
$('.menu-tab[data-target="js-memory"]').addClass('active');
$('.menu-tab[data-target="task"], .menu-tab[data-target="instructions"]').removeClass('active');
}, 300);
}
// ═══════════════════════════════════════════════════════════════════════════
// 全局导出
// ═══════════════════════════════════════════════════════════════════════════
window.processExistingMessages = processExistingMessages;
window.renderHtmlInIframe = renderHtmlInIframe;
window.registerModuleCleanup = registerModuleCleanup;
window.updateLittleWhiteBoxExtension = updateLittleWhiteBoxExtension;
window.removeAllUpdateNotices = removeAllUpdateNotices;
// ═══════════════════════════════════════════════════════════════════════════
// 入口初始化
// ═══════════════════════════════════════════════════════════════════════════
jQuery(async () => {
try {
cleanupDeprecatedData();
isXiaobaixEnabled = settings.enabled;
window.isXiaobaixEnabled = isXiaobaixEnabled;
if (isXiaobaixEnabled && settings.renderEnabled !== false) {
ensureHideCodeStyle(true);
setActiveClass(true);
}
if (!document.getElementById('xiaobaix-skeleton-style')) {
const skelStyle = document.createElement('style');
skelStyle.id = 'xiaobaix-skeleton-style';
skelStyle.textContent = `.xiaobaix-iframe-wrapper{position:relative}`;
document.head.appendChild(skelStyle);
}
const response = await fetch(`${extensionFolderPath}/style.css`);
const styleElement = document.createElement('style');
styleElement.textContent = await response.text();
document.head.appendChild(styleElement);
await setupSettings();
try { initControlAudio(); } catch (e) {}
if (isXiaobaixEnabled) {
initRenderer();
}
try {
if (isXiaobaixEnabled && settings.wrapperIframe && !document.getElementById('xb-callgen'))
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-callgen', type: 'module', src: `${extensionFolderPath}/bridges/call-generate-service.js` }));
} catch (e) {}
try {
if (isXiaobaixEnabled && !document.getElementById('xb-worldbook'))
document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-worldbook', type: 'module', src: `${extensionFolderPath}/bridges/worldbook-bridge.js` }));
} catch (e) {}
eventSource.on(event_types.APP_READY, () => {
setTimeout(performExtensionUpdateCheck, 2000);
});
if (isXiaobaixEnabled) {
try { initVarCommands(); } catch (e) {}
try { initVareventEditor(); } catch (e) {}
const moduleInits = [
{ condition: settings.tasks?.enabled, init: initTasks },
{ condition: settings.scriptAssistant?.enabled, init: initScriptAssistant },
{ condition: settings.immersive?.enabled, init: initImmersiveMode },
{ condition: settings.templateEditor?.enabled, init: initTemplateEditor },
{ condition: settings.wallhaven?.enabled, init: initWallhavenBackground },
{ condition: settings.fourthWall?.enabled, init: initFourthWall },
{ condition: settings.variablesPanel?.enabled, init: initVariablesPanel },
{ condition: settings.variablesCore?.enabled, init: initVariablesCore },
{ condition: settings.novelDraw?.enabled, init: initNovelDraw },
{ condition: true, init: initStreamingGeneration },
{ condition: true, init: initButtonCollapse }
];
moduleInits.forEach(({ condition, init }) => { if (condition) init(); });
if (settings.preview?.enabled || settings.recorded?.enabled) {
setTimeout(initMessagePreview, 1500);
}
}
setTimeout(setupMenuTabs, 500);
setTimeout(() => {
if (window.messagePreviewCleanup) {
registerModuleCleanup('messagePreview', window.messagePreviewCleanup);
}
}, 2000);
setInterval(() => {
if (isXiaobaixEnabled) processExistingMessages();
}, 30000);
} catch (err) {}
});
export { executeSlashCommand };

11
manifest.json Normal file
View File

@@ -0,0 +1,11 @@
{
"display_name": "LittleWhiteBox",
"loading_order": 10,
"requires": [],
"optional": [],
"js": "index.js",
"css": "style.css",
"author": "biex",
"version": "2.2.2",
"homePage": "https://github.com/RT15548/LittleWhiteBox"
}

257
modules/button-collapse.js Normal file
View File

@@ -0,0 +1,257 @@
let stylesInjected = false;
const SELECTORS = {
chat: '#chat',
messages: '.mes',
mesButtons: '.mes_block .mes_buttons',
buttons: '.memory-button, .dynamic-prompt-analysis-btn, .mes_history_preview',
collapse: '.xiaobaix-collapse-btn',
};
const XPOS_KEY = 'xiaobaix_x_btn_position';
const getXBtnPosition = () => {
try {
return (
window?.extension_settings?.LittleWhiteBox?.xBtnPosition ||
localStorage.getItem(XPOS_KEY) ||
'name-left'
);
} catch {
return 'name-left';
}
};
const injectStyles = () => {
if (stylesInjected) return;
const css = `
.mes_block .mes_buttons{align-items:center}
.xiaobaix-collapse-btn{
position:relative;display:inline-flex;width:32px;height:32px;justify-content:center;align-items:center;
border-radius:50%;background:var(--SmartThemeBlurTintColor);cursor:pointer;
box-shadow:inset 0 0 15px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.2);
transition:opacity .15s ease,transform .15s ease}
.xiaobaix-xstack{position:relative;display:inline-flex;align-items:center;justify-content:center;pointer-events:none}
.xiaobaix-xstack span{
position:absolute;font:italic 900 20px 'Arial Black',sans-serif;letter-spacing:-2px;transform:scaleX(.8);
text-shadow:0 0 10px rgba(255,255,255,.5),0 0 20px rgba(100,200,255,.3);color:#fff}
.xiaobaix-xstack span:nth-child(1){color:rgba(255,255,255,.1);transform:scaleX(.8) translateX(-8px);text-shadow:none}
.xiaobaix-xstack span:nth-child(2){color:rgba(255,255,255,.2);transform:scaleX(.8) translateX(-4px);text-shadow:none}
.xiaobaix-xstack span:nth-child(3){color:rgba(255,255,255,.4);transform:scaleX(.8) translateX(-2px);text-shadow:none}
.xiaobaix-sub-container{display:none;position:absolute;right:38px;border-radius:8px;padding:4px;gap:8px;pointer-events:auto}
.xiaobaix-collapse-btn.open .xiaobaix-sub-container{display:flex;background:var(--SmartThemeBlurTintColor)}
.xiaobaix-collapse-btn.open,.xiaobaix-collapse-btn.open ~ *{pointer-events:auto!important}
.mes_block .mes_buttons.xiaobaix-expanded{width:150px}
.xiaobaix-sub-container,.xiaobaix-sub-container *{pointer-events:auto!important}
.xiaobaix-sub-container .memory-button,.xiaobaix-sub-container .dynamic-prompt-analysis-btn,.xiaobaix-sub-container .mes_history_preview{opacity:1!important;filter:none!important}
.xiaobaix-sub-container.dir-right{left:38px;right:auto;z-index:1000;margin-top:2px}
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
stylesInjected = true;
};
const createCollapseButton = (dirRight) => {
injectStyles();
const btn = document.createElement('div');
btn.className = 'mes_btn xiaobaix-collapse-btn';
btn.innerHTML = `
<div class="xiaobaix-xstack"><span>X</span><span>X</span><span>X</span><span>X</span></div>
<div class="xiaobaix-sub-container${dirRight ? ' dir-right' : ''}"></div>
`;
const sub = btn.lastElementChild;
['click','pointerdown','pointerup'].forEach(t => {
sub.addEventListener(t, e => e.stopPropagation(), { passive: true });
});
btn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
const open = btn.classList.toggle('open');
const mesButtons = btn.closest(SELECTORS.mesButtons);
if (mesButtons) mesButtons.classList.toggle('xiaobaix-expanded', open);
});
return btn;
};
const findInsertPoint = (messageEl) => {
return messageEl.querySelector(
'.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignitemscenter,' +
'.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignItemsCenter'
);
};
const ensureCollapseForMessage = (messageEl, pos) => {
const mesButtons = messageEl.querySelector(SELECTORS.mesButtons);
if (!mesButtons) return null;
let collapseBtn = messageEl.querySelector(SELECTORS.collapse);
const dirRight = pos === 'edit-right';
if (!collapseBtn) collapseBtn = createCollapseButton(dirRight);
else collapseBtn.querySelector('.xiaobaix-sub-container')?.classList.toggle('dir-right', dirRight);
if (dirRight) {
const container = findInsertPoint(messageEl);
if (!container) return null;
if (collapseBtn.parentNode !== container) container.appendChild(collapseBtn);
} else {
if (mesButtons.lastElementChild !== collapseBtn) mesButtons.appendChild(collapseBtn);
}
return collapseBtn;
};
let processed = new WeakSet();
let io = null;
let mo = null;
let queue = [];
let rafScheduled = false;
const processOneMessage = (message) => {
if (!message || processed.has(message)) return;
const mesButtons = message.querySelector(SELECTORS.mesButtons);
if (!mesButtons) { processed.add(message); return; }
const pos = getXBtnPosition();
if (pos === 'edit-right' && !findInsertPoint(message)) { processed.add(message); return; }
const targetBtns = mesButtons.querySelectorAll(SELECTORS.buttons);
if (!targetBtns.length) { processed.add(message); return; }
const collapseBtn = ensureCollapseForMessage(message, pos);
if (!collapseBtn) { processed.add(message); return; }
const sub = collapseBtn.querySelector('.xiaobaix-sub-container');
const frag = document.createDocumentFragment();
targetBtns.forEach(b => frag.appendChild(b));
sub.appendChild(frag);
processed.add(message);
};
const ensureIO = () => {
if (io) return io;
io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
processOneMessage(e.target);
io.unobserve(e.target);
}
}, {
root: document.querySelector(SELECTORS.chat) || null,
rootMargin: '200px 0px',
threshold: 0
});
return io;
};
const observeVisibility = (nodes) => {
const obs = ensureIO();
nodes.forEach(n => { if (n && !processed.has(n)) obs.observe(n); });
};
const hookMutations = () => {
const chat = document.querySelector(SELECTORS.chat);
if (!chat) return;
if (!mo) {
mo = new MutationObserver((muts) => {
for (const m of muts) {
m.addedNodes && m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
const el = n;
if (el.matches?.(SELECTORS.messages)) queue.push(el);
else el.querySelectorAll?.(SELECTORS.messages)?.forEach(mes => queue.push(mes));
});
}
if (!rafScheduled && queue.length) {
rafScheduled = true;
requestAnimationFrame(() => {
observeVisibility(queue);
queue = [];
rafScheduled = false;
});
}
});
}
mo.observe(chat, { childList: true, subtree: true });
};
const processExistingVisible = () => {
const all = document.querySelectorAll(`${SELECTORS.chat} ${SELECTORS.messages}`);
if (!all.length) return;
const unprocessed = [];
all.forEach(n => { if (!processed.has(n)) unprocessed.push(n); });
if (unprocessed.length) observeVisibility(unprocessed);
};
const initButtonCollapse = () => {
injectStyles();
hookMutations();
processExistingVisible();
if (window && window['registerModuleCleanup']) {
try { window['registerModuleCleanup']('buttonCollapse', cleanup); } catch {}
}
};
const processButtonCollapse = () => {
processExistingVisible();
};
const registerButtonToSubContainer = (messageId, buttonEl) => {
if (!buttonEl) return false;
const message = document.querySelector(`${SELECTORS.chat} ${SELECTORS.messages}[mesid="${messageId}"]`);
if (!message) return false;
processOneMessage(message);
const pos = getXBtnPosition();
const collapseBtn = message.querySelector(SELECTORS.collapse) || ensureCollapseForMessage(message, pos);
if (!collapseBtn) return false;
const sub = collapseBtn.querySelector('.xiaobaix-sub-container');
sub.appendChild(buttonEl);
buttonEl.style.pointerEvents = 'auto';
buttonEl.style.opacity = '1';
return true;
};
const cleanup = () => {
io?.disconnect(); io = null;
mo?.disconnect(); mo = null;
queue = [];
rafScheduled = false;
document.querySelectorAll(SELECTORS.collapse).forEach(btn => {
const sub = btn.querySelector('.xiaobaix-sub-container');
const message = btn.closest(SELECTORS.messages) || btn.closest('.mes');
const mesButtons = message?.querySelector(SELECTORS.mesButtons) || message?.querySelector('.mes_buttons');
if (sub && mesButtons) {
mesButtons.classList.remove('xiaobaix-expanded');
const frag = document.createDocumentFragment();
while (sub.firstChild) frag.appendChild(sub.firstChild);
mesButtons.appendChild(frag);
}
btn.remove();
});
processed = new WeakSet();
};
if (typeof window !== 'undefined') {
Object.assign(window, {
initButtonCollapse,
cleanupButtonCollapse: cleanup,
registerButtonToSubContainer,
processButtonCollapse,
});
document.addEventListener('xiaobaixEnabledChanged', (e) => {
const en = e && e.detail && e.detail.enabled;
if (!en) cleanup();
});
}
export { initButtonCollapse, cleanup, registerButtonToSubContainer, processButtonCollapse };

268
modules/control-audio.js Normal file
View File

@@ -0,0 +1,268 @@
"use strict";
import { extension_settings } from "../../../../extensions.js";
import { eventSource, event_types } from "../../../../../script.js";
import { SlashCommandParser } from "../../../../slash-commands/SlashCommandParser.js";
import { SlashCommand } from "../../../../slash-commands/SlashCommand.js";
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from "../../../../slash-commands/SlashCommandArgument.js";
const AudioHost = (() => {
/** @typedef {{ audio: HTMLAudioElement|null, currentUrl: string }} AudioInstance */
/** @type {Record<'primary'|'secondary', AudioInstance>} */
const instances = {
primary: { audio: null, currentUrl: "" },
secondary: { audio: null, currentUrl: "" },
};
/**
* @param {('primary'|'secondary')} area
* @returns {HTMLAudioElement}
*/
function getOrCreate(area) {
const inst = instances[area] || (instances[area] = { audio: null, currentUrl: "" });
if (!inst.audio) {
inst.audio = new Audio();
inst.audio.preload = "auto";
try { inst.audio.crossOrigin = "anonymous"; } catch { }
}
return inst.audio;
}
/**
* @param {string} url
* @param {boolean} loop
* @param {('primary'|'secondary')} area
* @param {number} volume10 1-10
*/
async function playUrl(url, loop = false, area = 'primary', volume10 = 5) {
const u = String(url || "").trim();
if (!/^https?:\/\//i.test(u)) throw new Error("仅支持 http/https 链接");
const a = getOrCreate(area);
a.loop = !!loop;
let v = Number(volume10);
if (!Number.isFinite(v)) v = 5;
v = Math.max(1, Math.min(10, v));
try { a.volume = v / 10; } catch { }
const inst = instances[area];
if (inst.currentUrl && u === inst.currentUrl) {
if (a.paused) await a.play();
return `继续播放: ${u}`;
}
inst.currentUrl = u;
if (a.src !== u) {
a.src = u;
try { await a.play(); }
catch (e) { throw new Error("播放失败"); }
} else {
try { a.currentTime = 0; await a.play(); } catch { }
}
return `播放: ${u}`;
}
/**
* @param {('primary'|'secondary')} area
*/
function stop(area = 'primary') {
const inst = instances[area];
if (inst?.audio) {
try { inst.audio.pause(); } catch { }
}
return "已停止";
}
/**
* @param {('primary'|'secondary')} area
*/
function getCurrentUrl(area = 'primary') {
const inst = instances[area];
return inst?.currentUrl || "";
}
function reset() {
for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) {
const inst = instances[key];
if (inst.audio) {
try { inst.audio.pause(); } catch { }
try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { }
}
inst.currentUrl = "";
}
}
function stopAll() {
for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) {
const inst = instances[key];
if (inst?.audio) {
try { inst.audio.pause(); } catch { }
}
}
return "已全部停止";
}
/**
* 清除指定实例:停止并移除 src清空 currentUrl
* @param {('primary'|'secondary')} area
*/
function clear(area = 'primary') {
const inst = instances[area];
if (inst?.audio) {
try { inst.audio.pause(); } catch { }
try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { }
}
inst.currentUrl = "";
return "已清除";
}
return { playUrl, stop, stopAll, clear, getCurrentUrl, reset };
})();
let registeredCommand = null;
let chatChangedHandler = null;
let isRegistered = false;
let globalStateChangedHandler = null;
function registerSlash() {
if (isRegistered) return;
try {
registeredCommand = SlashCommand.fromProps({
name: "xbaudio",
callback: async (args, value) => {
try {
const action = String(args.play || "").toLowerCase();
const mode = String(args.mode || "loop").toLowerCase();
const rawArea = args.area;
const hasArea = typeof rawArea !== 'undefined' && rawArea !== null && String(rawArea).trim() !== '';
const area = hasArea && String(rawArea).toLowerCase() === 'secondary' ? 'secondary' : 'primary';
const volumeArg = args.volume;
let volume = Number(volumeArg);
if (!Number.isFinite(volume)) volume = 5;
const url = String(value || "").trim();
const loop = mode === "loop";
if (url.toLowerCase() === "list") {
return AudioHost.getCurrentUrl(area) || "";
}
if (action === "off") {
if (hasArea) {
return AudioHost.stop(area);
}
return AudioHost.stopAll();
}
if (action === "clear") {
if (hasArea) {
return AudioHost.clear(area);
}
AudioHost.reset();
return "已全部清除";
}
if (action === "on" || (!action && url)) {
return await AudioHost.playUrl(url, loop, area, volume);
}
if (!url && !action) {
const cur = AudioHost.getCurrentUrl(area);
return cur ? `当前播放(${area}): ${cur}` : "未在播放。用法: /xbaudio [play=on] [mode=loop] [area=primary/secondary] [volume=5] URL | /xbaudio list | /xbaudio play=off (未指定 area 将关闭全部)";
}
return "用法: /xbaudio play=off | /xbaudio play=off area=primary/secondary | /xbaudio play=clear | /xbaudio play=clear area=primary/secondary | /xbaudio [play=on] [mode=loop/once] [area=primary/secondary] [volume=1-10] URL | /xbaudio list (默认: play=on mode=loop area=primary volume=5未指定 area 的 play=off 关闭全部;未指定 area 的 play=clear 清除全部)";
} catch (e) {
return `错误: ${e.message || e}`;
}
},
namedArgumentList: [
SlashCommandNamedArgument.fromProps({ name: "play", description: "on/off/clear 或留空以默认播放", typeList: [ARGUMENT_TYPE.STRING], enumList: ["on", "off", "clear"] }),
SlashCommandNamedArgument.fromProps({ name: "mode", description: "once/loop", typeList: [ARGUMENT_TYPE.STRING], enumList: ["once", "loop"] }),
SlashCommandNamedArgument.fromProps({ name: "area", description: "primary/secondary (play=off 未指定 area 关闭全部)", typeList: [ARGUMENT_TYPE.STRING], enumList: ["primary", "secondary"] }),
SlashCommandNamedArgument.fromProps({ name: "volume", description: "音量 1-10默认 5", typeList: [ARGUMENT_TYPE.NUMBER] }),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({ description: "音频URL (http/https) 或 list", typeList: [ARGUMENT_TYPE.STRING] }),
],
helpString: "播放网络音频。示例: /xbaudio https://files.catbox.moe/0ryoa5.mp3 (默认: play=on mode=loop area=primary volume=5) | /xbaudio area=secondary volume=8 https://files.catbox.moe/0ryoa5.mp3 | /xbaudio list | /xbaudio play=off (未指定 area 关闭全部) | /xbaudio play=off area=primary | /xbaudio play=clear (未指定 area 清除全部)",
});
SlashCommandParser.addCommandObject(registeredCommand);
if (event_types?.CHAT_CHANGED) {
chatChangedHandler = () => { try { AudioHost.reset(); } catch { } };
eventSource.on(event_types.CHAT_CHANGED, chatChangedHandler);
}
isRegistered = true;
} catch (e) {
console.error("[LittleWhiteBox][audio] 注册斜杠命令失败", e);
}
}
function unregisterSlash() {
if (!isRegistered) return;
try {
if (chatChangedHandler && event_types?.CHAT_CHANGED) {
try { eventSource.removeListener(event_types.CHAT_CHANGED, chatChangedHandler); } catch { }
}
chatChangedHandler = null;
try {
const map = SlashCommandParser.commands || {};
Object.keys(map).forEach((k) => { if (map[k] === registeredCommand) delete map[k]; });
} catch { }
} finally {
registeredCommand = null;
isRegistered = false;
}
}
function enableFeature() {
registerSlash();
}
function disableFeature() {
try { AudioHost.reset(); } catch { }
unregisterSlash();
}
export function initControlAudio() {
try {
try {
const enabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true);
if (enabled) enableFeature(); else disableFeature();
} catch { enableFeature(); }
const bind = () => {
const cb = document.getElementById('xiaobaix_audio_enabled');
if (!cb) { setTimeout(bind, 200); return; }
const applyState = () => {
const input = /** @type {HTMLInputElement} */(cb);
const enabled = !!(input && input.checked);
if (enabled) enableFeature(); else disableFeature();
};
cb.addEventListener('change', applyState);
applyState();
};
bind();
// 监听扩展全局开关,关闭时强制停止并清理两个实例
try {
if (!globalStateChangedHandler) {
globalStateChangedHandler = (e) => {
try {
const enabled = !!(e && e.detail && e.detail.enabled);
if (!enabled) {
try { AudioHost.reset(); } catch { }
unregisterSlash();
} else {
// 重新根据子开关状态应用
const audioEnabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true);
if (audioEnabled) enableFeature(); else disableFeature();
}
} catch { }
};
document.addEventListener('xiaobaixEnabledChanged', globalStateChangedHandler);
}
} catch { }
} catch (e) {
console.error("[LittleWhiteBox][audio] 初始化失败", e);
}
}

View File

@@ -0,0 +1,765 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LittleWhiteBox 监控台</title>
<style>
:root {
--border: rgba(255,255,255,0.10);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--info: #bdbdbd;
--warn: #ffcc66;
--error: #ff6b6b;
--accent: #7aa2ff;
--success: #4ade80;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
font-size: 12px;
}
html, body {
height: 100%;
margin: 0;
background: transparent;
color: var(--text);
}
.root {
height: 100%;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--border);
background: rgba(18,18,18,0.65);
backdrop-filter: blur(6px);
flex-shrink: 0;
}
.content {
flex: 1;
overflow: auto;
padding: 10px;
}
.tabs {
display: flex;
gap: 6px;
}
.tab {
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
border: 1px solid var(--border);
background: rgba(255,255,255,0.04);
user-select: none;
}
.tab.active {
border-color: rgba(122,162,255,0.55);
background: rgba(122,162,255,0.10);
}
button {
border: 1px solid var(--border);
background: rgba(255,255,255,0.05);
color: var(--text);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
}
button:hover {
background: rgba(255,255,255,0.09);
}
select {
border: 1px solid var(--border);
background: rgba(0,0,0,0.25);
color: var(--text);
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
outline: none;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;
}
.card {
border: 1px solid var(--border);
background: rgba(0,0,0,0.18);
border-radius: 10px;
overflow: hidden;
}
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
.empty-hint { padding: 20px; text-align: center; color: var(--muted); }
/* 日志 */
.log-item {
padding: 8px 10px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.log-item:last-child { border-bottom: none; }
.log-header {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
}
.log-toggle {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--muted);
font-size: 10px;
flex-shrink: 0;
border-radius: 4px;
user-select: none;
transition: transform 0.15s;
}
.log-toggle:hover { background: rgba(255,255,255,0.1); }
.log-toggle.empty { visibility: hidden; cursor: default; }
.log-item.open .log-toggle { transform: rotate(90deg); }
.time { color: var(--muted); }
.lvl { font-weight: 700; }
.lvl.info { color: var(--info); }
.lvl.warn { color: var(--warn); }
.lvl.error { color: var(--error); }
.mod { color: var(--accent); }
.msg { color: var(--text); word-break: break-word; }
.stack {
margin: 8px 0 0 24px;
padding: 10px;
white-space: pre-wrap;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: rgba(255,255,255,0.85);
background: rgba(0,0,0,0.3);
border-radius: 6px;
font-size: 11px;
display: none;
}
.log-item.open .stack { display: block; }
/* 事件 */
.section-collapse { margin-bottom: 12px; }
.section-collapse-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
cursor: pointer;
border-radius: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
user-select: none;
}
.section-collapse-header:hover { background: rgba(255,255,255,0.06); }
.section-collapse-header .arrow {
transition: transform 0.2s;
color: var(--muted);
font-size: 10px;
}
.section-collapse-header.open .arrow { transform: rotate(90deg); }
.section-collapse-header .title { flex: 1; }
.section-collapse-header .count { color: var(--muted); font-size: 11px; }
.section-collapse-content {
display: none;
padding: 8px 0 0 0;
max-height: 200px;
overflow-y: auto;
}
.section-collapse-header.open + .section-collapse-content { display: block; }
.module-section { margin-bottom: 8px; }
.module-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
cursor: pointer;
border-radius: 6px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.06);
user-select: none;
}
.module-header:hover { background: rgba(255,255,255,0.05); }
.module-header .arrow { transition: transform 0.2s; color: var(--muted); font-size: 9px; }
.module-header.open .arrow { transform: rotate(90deg); }
.module-header .name { color: var(--accent); font-weight: 600; }
.module-header .count { color: var(--muted); }
.module-events { display: none; padding: 6px 10px 6px 28px; }
.module-header.open + .module-events { display: block; }
.event-tag {
display: inline-block;
padding: 2px 8px;
margin: 2px 4px 2px 0;
border-radius: 6px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
font-size: 11px;
font-family: ui-monospace, monospace;
}
.event-tag .dup { color: var(--error); font-weight: 700; margin-left: 4px; }
.pill {
display: inline-flex;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.05);
}
.repeat-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: rgba(255,255,255,0.12);
color: var(--muted);
font-size: 10px;
font-weight: 600;
margin-left: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* 缓存 */
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); text-align: left; }
th { color: rgba(255,255,255,0.75); font-weight: 600; }
.right { text-align: right; }
.cache-detail-row { display: none; }
.cache-detail-row.open { display: table-row; }
.cache-detail-row td { padding: 0; }
.pre {
padding: 10px;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
color: rgba(255,255,255,0.80);
background: rgba(0,0,0,0.25);
font-size: 11px;
}
/* 性能 */
.perf-overview {
display: flex;
gap: 16px;
flex-wrap: wrap;
padding: 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(0,0,0,0.18);
}
.perf-stat { display: flex; flex-direction: column; gap: 2px; }
.perf-stat .label { font-size: 10px; color: var(--muted); text-transform: uppercase; }
.perf-stat .value { font-size: 16px; font-weight: 700; }
.perf-stat .value.good { color: var(--success); }
.perf-stat .value.warn { color: var(--warn); }
.perf-stat .value.bad { color: var(--error); }
.perf-section { margin-bottom: 16px; }
.perf-section-title {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.perf-item { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.perf-item:last-child { border-bottom: none; }
.perf-item .top { display: flex; gap: 8px; align-items: baseline; }
.perf-item .url { flex: 1; font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; }
.perf-item .duration { font-weight: 700; }
.perf-item .duration.slow { color: var(--warn); }
.perf-item .duration.very-slow { color: var(--error); }
</style>
</head>
<body>
<div class="root">
<div class="topbar">
<div class="tabs">
<div class="tab active" data-tab="logs">日志</div>
<div class="tab" data-tab="events">事件</div>
<div class="tab" data-tab="caches">缓存</div>
<div class="tab" data-tab="performance">性能</div>
</div>
<button id="btn-refresh" type="button">刷新</button>
</div>
<div class="content">
<section id="tab-logs">
<div class="row">
<span class="muted">过滤</span>
<select id="log-level"><option value="all">全部</option><option value="info">INFO</option><option value="warn">WARN</option><option value="error">ERROR</option></select>
<span class="muted">模块</span>
<select id="log-module"><option value="all">全部</option></select>
<button id="btn-clear-logs" type="button">清空</button>
<span class="muted" id="log-count"></span>
</div>
<div class="card" id="log-list"></div>
</section>
<section id="tab-events" style="display:none">
<div class="section-collapse">
<div class="section-collapse-header" id="module-section-header">
<span class="arrow"></span>
<span class="title">模块事件监听</span>
<span class="count" id="module-count"></span>
</div>
<div class="section-collapse-content" id="module-list"></div>
</div>
<div class="row">
<span class="muted">触发历史</span>
<button id="btn-clear-events" type="button">清空历史</button>
</div>
<div class="card" id="event-list"></div>
</section>
<section id="tab-caches" style="display:none">
<div class="row">
<button id="btn-clear-all-caches" type="button">清理全部</button>
<span class="muted" id="cache-count"></span>
</div>
<div class="card" id="cache-card">
<table>
<thead><tr><th>缓存项目</th><th>条数</th><th>大小</th><th class="right">操作</th></tr></thead>
<tbody id="cache-tbody"></tbody>
</table>
<div id="cache-empty" class="empty-hint" style="display:none;">暂无缓存注册</div>
</div>
</section>
<section id="tab-performance" style="display:none">
<div class="perf-overview">
<div class="perf-stat"><span class="label">FPS</span><span class="value" id="perf-fps">--</span></div>
<div class="perf-stat" id="perf-memory-stat"><span class="label">内存</span><span class="value" id="perf-memory">--</span></div>
<div class="perf-stat"><span class="label">DOM</span><span class="value" id="perf-dom">--</span></div>
<div class="perf-stat"><span class="label">消息</span><span class="value" id="perf-messages">--</span></div>
<div class="perf-stat"><span class="label">图片</span><span class="value" id="perf-images">--</span></div>
</div>
<div class="perf-section">
<div class="perf-section-title"><span>慢请求 (≥500ms)</span><button id="btn-clear-requests" type="button">清空</button></div>
<div class="card" id="perf-requests"></div>
</div>
<div class="perf-section">
<div class="perf-section-title"><span>长任务</span><button id="btn-clear-tasks" type="button">清空</button></div>
<div class="card" id="perf-tasks"></div>
</div>
</section>
</div>
</div>
<script type="module">
const post = (payload) => {
try { parent.postMessage({ source: 'LittleWhiteBox-DebugFrame', ...payload }, '*'); } catch {}
};
// ═══════════════════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════════════════
const state = {
logs: [],
events: [],
eventStatsDetail: {},
caches: [],
performance: {},
openCacheDetail: null,
cacheDetails: {},
openModules: new Set(),
openLogIds: new Set(),
pendingData: null,
mouseDown: false,
};
// ═══════════════════════════════════════════════════════════════════════
// 用户交互检测 - 核心:交互时不刷新
// ═══════════════════════════════════════════════════════════════════════
document.addEventListener('mousedown', () => { state.mouseDown = true; });
document.addEventListener('mouseup', () => {
state.mouseDown = false;
// 鼠标抬起后,如果有待处理数据,延迟一点再应用(让用户完成选择)
if (state.pendingData) {
setTimeout(() => {
if (!isUserInteracting() && state.pendingData) {
applyData(state.pendingData);
state.pendingData = null;
}
}, 300);
}
});
function isUserInteracting() {
// 1. 鼠标按下中
if (state.mouseDown) return true;
// 2. 有文字被选中
const sel = document.getSelection();
if (sel && sel.toString().length > 0) return true;
// 3. 焦点在输入元素上
const active = document.activeElement;
if (active && (active.tagName === 'INPUT' || active.tagName === 'SELECT' || active.tagName === 'TEXTAREA')) return true;
return false;
}
// ═══════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════
const fmtTime = (ts) => {
try {
const d = new Date(Number(ts) || Date.now());
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}`;
} catch { return '--:--:--'; }
};
const fmtBytes = (n) => {
const v = Number(n);
if (!Number.isFinite(v) || v <= 0) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let idx = 0, x = v;
while (x >= 1024 && idx < units.length - 1) { x /= 1024; idx++; }
return `${x.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
};
const fmtMB = (bytes) => Number.isFinite(bytes) && bytes > 0 ? (bytes / 1048576).toFixed(0) + 'MB' : '--';
const escapeHtml = (s) => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
// ═══════════════════════════════════════════════════════════════════════
// 日志渲染
// ═══════════════════════════════════════════════════════════════════════
function getLogFilters() {
return {
level: document.getElementById('log-level').value,
module: document.getElementById('log-module').value
};
}
function filteredLogs() {
const f = getLogFilters();
return (state.logs || []).filter(l => {
if (!l) return false;
if (f.level !== 'all' && l.level !== f.level) return false;
if (f.module !== 'all' && String(l.module) !== f.module) return false;
return true;
});
}
function renderLogModuleOptions() {
const sel = document.getElementById('log-module');
const current = sel.value || 'all';
const mods = [...new Set((state.logs || []).map(l => l?.module).filter(Boolean))].sort();
sel.innerHTML = `<option value="all">全部</option>` + mods.map(m => `<option value="${escapeHtml(m)}">${escapeHtml(m)}</option>`).join('');
if ([...sel.options].some(o => o.value === current)) sel.value = current;
}
function renderLogs() {
renderLogModuleOptions();
const logs = filteredLogs();
document.getElementById('log-count').textContent = `${logs.length}`;
const list = document.getElementById('log-list');
if (!logs.length) {
list.innerHTML = `<div class="empty-hint">暂无日志</div>`;
return;
}
// 清理已不存在的ID
const currentIds = new Set(logs.map(l => l.id));
for (const id of state.openLogIds) {
if (!currentIds.has(id)) state.openLogIds.delete(id);
}
list.innerHTML = logs.map(l => {
const lvl = escapeHtml(l.level || 'info');
const mod = escapeHtml(l.module || 'unknown');
const msg = escapeHtml(l.message || '');
const stack = l.stack ? escapeHtml(String(l.stack)) : '';
const hasStack = !!stack;
const isOpen = state.openLogIds.has(l.id);
return `<div class="log-item${isOpen ? ' open' : ''}" data-id="${l.id}">
<div class="log-header">
<span class="log-toggle${hasStack ? '' : ' empty'}" data-id="${l.id}">▶</span>
<span class="time">${fmtTime(l.timestamp)}</span>
<span class="lvl ${lvl}">[${lvl.toUpperCase()}]</span>
<span class="mod">${mod}</span>
<span class="msg">${msg}</span>
</div>
${hasStack ? `<div class="stack">${stack}</div>` : ''}
</div>`;
}).join('');
// 绑定展开事件
list.querySelectorAll('.log-toggle:not(.empty)').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const id = Number(toggle.getAttribute('data-id'));
const item = toggle.closest('.log-item');
if (state.openLogIds.has(id)) {
state.openLogIds.delete(id);
item.classList.remove('open');
} else {
state.openLogIds.add(id);
item.classList.add('open');
}
});
});
}
// ═══════════════════════════════════════════════════════════════════════
// 事件渲染
// ═══════════════════════════════════════════════════════════════════════
function renderModuleList() {
const detail = state.eventStatsDetail || {};
const modules = Object.keys(detail).sort();
const container = document.getElementById('module-list');
const countEl = document.getElementById('module-count');
const totalListeners = Object.values(detail).reduce((sum, m) => sum + (m.total || 0), 0);
if (countEl) countEl.textContent = `${modules.length} 模块 · ${totalListeners} 监听器`;
if (!modules.length) {
container.innerHTML = `<div class="muted" style="padding:10px;">暂无模块监听</div>`;
return;
}
container.innerHTML = modules.map(mod => {
const info = detail[mod] || {};
const events = info.events || {};
const isOpen = state.openModules.has(mod);
const eventTags = Object.keys(events).sort().map(ev => {
const cnt = events[ev];
return `<span class="event-tag">${escapeHtml(ev)}${cnt > 1 ? `<span class="dup">×${cnt}</span>` : ''}</span>`;
}).join('');
return `<div class="module-section">
<div class="module-header${isOpen ? ' open' : ''}" data-mod="${escapeHtml(mod)}">
<span class="arrow">▶</span>
<span class="name">${escapeHtml(mod)}</span>
<span class="count">(${info.total || 0})</span>
</div>
<div class="module-events">${eventTags || '<span class="muted">无事件</span>'}</div>
</div>`;
}).join('');
container.querySelectorAll('.module-header').forEach(el => {
el.addEventListener('click', () => {
const mod = el.getAttribute('data-mod');
state.openModules.has(mod) ? state.openModules.delete(mod) : state.openModules.add(mod);
renderModuleList();
});
});
}
function renderEvents() {
renderModuleList();
const list = document.getElementById('event-list');
const events = state.events || [];
if (!events.length) {
list.innerHTML = `<div class="empty-hint">暂无事件历史</div>`;
return;
}
list.innerHTML = events.slice().reverse().map(e => {
const repeat = (e.repeatCount || 1) > 1 ? `<span class="repeat-badge">×${e.repeatCount}</span>` : '';
return `<div class="log-item"><div class="log-header">
<span class="time">${fmtTime(e.timestamp)}</span>
<span class="pill mono">${escapeHtml(e.type || 'CUSTOM')}</span>
<span class="mod">${escapeHtml(e.eventName || '')}</span>
${repeat}
<span class="msg">${escapeHtml(e.dataSummary || '')}</span>
</div></div>`;
}).join('');
}
// ═══════════════════════════════════════════════════════════════════════
// 缓存渲染
// ═══════════════════════════════════════════════════════════════════════
function renderCaches() {
const caches = state.caches || [];
document.getElementById('cache-count').textContent = `${caches.length}`;
const tbody = document.getElementById('cache-tbody');
const emptyHint = document.getElementById('cache-empty');
const table = tbody.closest('table');
if (!caches.length) {
table.style.display = 'none';
emptyHint.style.display = '';
return;
}
table.style.display = '';
emptyHint.style.display = 'none';
let html = '';
for (const c of caches) {
const mid = escapeHtml(c.moduleId);
const isOpen = state.openCacheDetail === c.moduleId;
const detailBtn = c.hasDetail ? `<button data-act="detail" data-mid="${mid}">${isOpen ? '收起' : '详情'}</button>` : '';
html += `<tr>
<td>${escapeHtml(c.name || c.moduleId)}<div class="muted mono">${mid}</div></td>
<td>${c.size == null ? '-' : c.size}</td>
<td>${fmtBytes(c.bytes)}</td>
<td class="right">${detailBtn}<button data-act="clear" data-mid="${mid}">清理</button></td>
</tr>`;
if (isOpen && state.cacheDetails[c.moduleId] !== undefined) {
html += `<tr class="cache-detail-row open"><td colspan="4"><div class="pre">${escapeHtml(JSON.stringify(state.cacheDetails[c.moduleId], null, 2))}</div></td></tr>`;
}
}
tbody.innerHTML = html;
tbody.querySelectorAll('button[data-act]').forEach(btn => {
btn.addEventListener('click', e => {
const act = btn.getAttribute('data-act');
const mid = btn.getAttribute('data-mid');
if (act === 'clear') {
if (confirm(`确定清理缓存:${mid}`)) post({ type: 'XB_DEBUG_ACTION', action: 'clearCache', moduleId: mid });
} else if (act === 'detail') {
state.openCacheDetail = state.openCacheDetail === mid ? null : mid;
if (state.openCacheDetail) post({ type: 'XB_DEBUG_ACTION', action: 'cacheDetail', moduleId: mid });
else renderCaches();
}
});
});
}
// ═══════════════════════════════════════════════════════════════════════
// 性能渲染
// ═══════════════════════════════════════════════════════════════════════
function renderPerformance() {
const perf = state.performance || {};
const fps = perf.fps || 0;
const fpsEl = document.getElementById('perf-fps');
fpsEl.textContent = fps > 0 ? fps : '--';
fpsEl.className = 'value' + (fps >= 50 ? ' good' : fps >= 30 ? ' warn' : fps > 0 ? ' bad' : '');
const memEl = document.getElementById('perf-memory');
const memStat = document.getElementById('perf-memory-stat');
if (perf.memory) {
const pct = perf.memory.total > 0 ? (perf.memory.used / perf.memory.total * 100) : 0;
memEl.textContent = fmtMB(perf.memory.used);
memEl.className = 'value' + (pct < 60 ? ' good' : pct < 80 ? ' warn' : ' bad');
memStat.style.display = '';
} else {
memStat.style.display = 'none';
}
const dom = perf.domCount || 0;
const domEl = document.getElementById('perf-dom');
domEl.textContent = dom.toLocaleString();
domEl.className = 'value' + (dom < 3000 ? ' good' : dom < 6000 ? ' warn' : ' bad');
const msg = perf.messageCount || 0;
document.getElementById('perf-messages').textContent = msg;
document.getElementById('perf-messages').className = 'value' + (msg < 100 ? ' good' : msg < 300 ? ' warn' : ' bad');
const img = perf.imageCount || 0;
document.getElementById('perf-images').textContent = img;
document.getElementById('perf-images').className = 'value' + (img < 50 ? ' good' : img < 100 ? ' warn' : ' bad');
const reqContainer = document.getElementById('perf-requests');
const requests = perf.requests || [];
reqContainer.innerHTML = requests.length ? requests.slice().reverse().map(r => {
const durClass = r.duration >= 2000 ? 'very-slow' : r.duration >= 1000 ? 'slow' : '';
return `<div class="perf-item"><div class="top"><span class="time">${fmtTime(r.timestamp)}</span><span class="pill mono">${escapeHtml(r.method)}</span><span class="url">${escapeHtml(r.url)}</span><span class="duration ${durClass}">${r.duration}ms</span></div></div>`;
}).join('') : `<div class="empty-hint">暂无慢请求</div>`;
const taskContainer = document.getElementById('perf-tasks');
const tasks = perf.longTasks || [];
taskContainer.innerHTML = tasks.length ? tasks.slice().reverse().map(t => {
const durClass = t.duration >= 200 ? 'very-slow' : t.duration >= 100 ? 'slow' : '';
return `<div class="perf-item"><div class="top"><span class="time">${fmtTime(t.timestamp)}</span><span class="duration ${durClass}">${t.duration}ms</span><span class="muted">${escapeHtml(t.source || '未知')}</span></div></div>`;
}).join('') : `<div class="empty-hint">暂无长任务</div>`;
}
// ═══════════════════════════════════════════════════════════════════════
// Tab 切换
// ═══════════════════════════════════════════════════════════════════════
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
['logs', 'events', 'caches', 'performance'].forEach(name => {
document.getElementById(`tab-${name}`).style.display = name === tab ? '' : 'none';
});
}
// ═══════════════════════════════════════════════════════════════════════
// 数据应用
// ═══════════════════════════════════════════════════════════════════════
function applyData(payload) {
state.logs = payload?.logs || [];
state.events = payload?.events || [];
state.eventStatsDetail = payload?.eventStatsDetail || {};
state.caches = payload?.caches || [];
state.performance = payload?.performance || {};
renderLogs();
renderEvents();
renderCaches();
renderPerformance();
}
// ═══════════════════════════════════════════════════════════════════════
// 事件绑定
// ═══════════════════════════════════════════════════════════════════════
document.getElementById('btn-refresh').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'refresh' }));
document.getElementById('btn-clear-logs').addEventListener('click', () => {
if (confirm('确定清空日志?')) {
state.openLogIds.clear();
post({ type: 'XB_DEBUG_ACTION', action: 'clearLogs' });
}
});
document.getElementById('btn-clear-events').addEventListener('click', () => {
if (confirm('确定清空事件历史?')) post({ type: 'XB_DEBUG_ACTION', action: 'clearEvents' });
});
document.getElementById('btn-clear-all-caches').addEventListener('click', () => {
if (confirm('确定清理全部缓存?')) post({ type: 'XB_DEBUG_ACTION', action: 'clearAllCaches' });
});
document.getElementById('btn-clear-requests').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'clearRequests' }));
document.getElementById('btn-clear-tasks').addEventListener('click', () => post({ type: 'XB_DEBUG_ACTION', action: 'clearTasks' }));
document.getElementById('log-level').addEventListener('change', renderLogs);
document.getElementById('log-module').addEventListener('change', renderLogs);
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
document.getElementById('module-section-header')?.addEventListener('click', function() { this.classList.toggle('open'); });
// ═══════════════════════════════════════════════════════════════════════
// 消息监听
// ═══════════════════════════════════════════════════════════════════════
window.addEventListener('message', (event) => {
const msg = event?.data;
if (!msg || msg.source !== 'LittleWhiteBox-DebugHost') return;
if (msg.type === 'XB_DEBUG_DATA') {
// 核心逻辑用户交互时暂存数据不刷新DOM
if (isUserInteracting()) {
state.pendingData = msg.payload;
} else {
applyData(msg.payload);
state.pendingData = null;
}
}
if (msg.type === 'XB_DEBUG_CACHE_DETAIL') {
const mid = msg.payload?.moduleId;
if (mid) {
state.cacheDetails[mid] = msg.payload?.detail;
renderCaches();
}
}
});
post({ type: 'FRAME_READY' });
</script>
</body>
</html>

View File

@@ -0,0 +1,743 @@
// ═══════════════════════════════════════════════════════════════════════════
// 导入和常量
// ═══════════════════════════════════════════════════════════════════════════
import { extensionFolderPath } from "../../core/constants.js";
const STORAGE_EXPANDED_KEY = "xiaobaix_debug_panel_pos_v2";
const STORAGE_MINI_KEY = "xiaobaix_debug_panel_minipos_v2";
// ═══════════════════════════════════════════════════════════════════════════
// 状态变量
// ═══════════════════════════════════════════════════════════════════════════
let isOpen = false;
let isExpanded = false;
let panelEl = null;
let miniBtnEl = null;
let iframeEl = null;
let dragState = null;
let pollTimer = null;
let lastLogId = 0;
let frameReady = false;
let messageListenerBound = false;
let resizeHandler = null;
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控状态
// ═══════════════════════════════════════════════════════════════════════════
let perfMonitorActive = false;
let originalFetch = null;
let longTaskObserver = null;
let fpsFrameId = null;
let lastFrameTime = 0;
let frameCount = 0;
let currentFps = 0;
const requestLog = [];
const longTaskLog = [];
const MAX_PERF_LOG = 50;
const SLOW_REQUEST_THRESHOLD = 500;
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const isMobile = () => window.innerWidth <= 768;
const countErrors = (logs) => (logs || []).filter(l => l?.level === "error").length;
const maxLogId = (logs) => (logs || []).reduce((m, l) => Math.max(m, Number(l?.id) || 0), 0);
// ═══════════════════════════════════════════════════════════════════════════
// 存储
// ═══════════════════════════════════════════════════════════════════════════
function readJSON(key) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function writeJSON(key, data) {
try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
}
// ═══════════════════════════════════════════════════════════════════════════
// 页面统计
// ═══════════════════════════════════════════════════════════════════════════
function getPageStats() {
try {
return {
domCount: document.querySelectorAll('*').length,
messageCount: document.querySelectorAll('.mes').length,
imageCount: document.querySelectorAll('img').length
};
} catch {
return { domCount: 0, messageCount: 0, imageCount: 0 };
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控Fetch 拦截
// ═══════════════════════════════════════════════════════════════════════════
function startFetchInterceptor() {
if (originalFetch) return;
originalFetch = window.fetch;
window.fetch = async function(input, init) {
const url = typeof input === 'string' ? input : input?.url || '';
const method = init?.method || 'GET';
const startTime = performance.now();
const timestamp = Date.now();
try {
const response = await originalFetch.apply(this, arguments);
const duration = performance.now() - startTime;
if (url.includes('/api/') && duration >= SLOW_REQUEST_THRESHOLD) {
requestLog.push({ url, method, duration: Math.round(duration), timestamp, status: response.status });
if (requestLog.length > MAX_PERF_LOG) requestLog.shift();
}
return response;
} catch (err) {
const duration = performance.now() - startTime;
requestLog.push({ url, method, duration: Math.round(duration), timestamp, status: 'error' });
if (requestLog.length > MAX_PERF_LOG) requestLog.shift();
throw err;
}
};
}
function stopFetchInterceptor() {
if (originalFetch) {
window.fetch = originalFetch;
originalFetch = null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控:长任务检测
// ═══════════════════════════════════════════════════════════════════════════
function startLongTaskObserver() {
if (longTaskObserver) return;
try {
if (typeof PerformanceObserver === 'undefined') return;
longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration >= 200) {
let source = '主页面';
try {
const attr = entry.attribution?.[0];
if (attr) {
if (attr.containerType === 'iframe') {
source = 'iframe';
if (attr.containerSrc) {
const url = new URL(attr.containerSrc, location.href);
source += `: ${url.pathname.split('/').pop() || url.pathname}`;
}
} else if (attr.containerName) {
source = attr.containerName;
}
}
} catch {}
longTaskLog.push({
duration: Math.round(entry.duration),
timestamp: Date.now(),
source
});
if (longTaskLog.length > MAX_PERF_LOG) longTaskLog.shift();
}
}
});
longTaskObserver.observe({ entryTypes: ['longtask'] });
} catch {}
}
function stopLongTaskObserver() {
if (longTaskObserver) {
try { longTaskObserver.disconnect(); } catch {}
longTaskObserver = null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控FPS 计算
// ═══════════════════════════════════════════════════════════════════════════
function startFpsMonitor() {
if (fpsFrameId) return;
lastFrameTime = performance.now();
frameCount = 0;
const loop = (now) => {
frameCount++;
if (now - lastFrameTime >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFrameTime = now;
}
fpsFrameId = requestAnimationFrame(loop);
};
fpsFrameId = requestAnimationFrame(loop);
}
function stopFpsMonitor() {
if (fpsFrameId) {
cancelAnimationFrame(fpsFrameId);
fpsFrameId = null;
}
currentFps = 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控:内存
// ═══════════════════════════════════════════════════════════════════════════
function getMemoryInfo() {
if (typeof performance === 'undefined' || !performance.memory) return null;
const mem = performance.memory;
return {
used: mem.usedJSHeapSize,
total: mem.totalJSHeapSize,
limit: mem.jsHeapSizeLimit
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 性能监控:生命周期
// ═══════════════════════════════════════════════════════════════════════════
function startPerfMonitor() {
if (perfMonitorActive) return;
perfMonitorActive = true;
startFetchInterceptor();
startLongTaskObserver();
startFpsMonitor();
}
function stopPerfMonitor() {
if (!perfMonitorActive) return;
perfMonitorActive = false;
stopFetchInterceptor();
stopLongTaskObserver();
stopFpsMonitor();
requestLog.length = 0;
longTaskLog.length = 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// 样式注入
// ═══════════════════════════════════════════════════════════════════════════
function ensureStyle() {
if (document.getElementById("xiaobaix-debug-style")) return;
const style = document.createElement("style");
style.id = "xiaobaix-debug-style";
style.textContent = `
#xiaobaix-debug-btn {
display: inline-flex !important;
align-items: center !important;
gap: 6px !important;
}
#xiaobaix-debug-btn .dbg-light {
width: 8px;
height: 8px;
border-radius: 50%;
background: #555;
flex-shrink: 0;
transition: background 0.2s, box-shadow 0.2s;
}
#xiaobaix-debug-btn .dbg-light.on {
background: #4ade80;
box-shadow: 0 0 6px #4ade80;
}
#xiaobaix-debug-mini {
position: fixed;
z-index: 10000;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(28, 28, 32, 0.96);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: rgba(255,255,255,0.9);
font-size: 12px;
cursor: pointer;
user-select: none;
touch-action: none;
box-shadow: 0 4px 14px rgba(0,0,0,0.35);
transition: box-shadow 0.2s;
}
#xiaobaix-debug-mini:hover {
box-shadow: 0 6px 18px rgba(0,0,0,0.45);
}
#xiaobaix-debug-mini .badge {
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,80,80,0.18);
border: 1px solid rgba(255,80,80,0.35);
color: #fca5a5;
font-size: 10px;
}
#xiaobaix-debug-mini .badge.hidden { display: none; }
#xiaobaix-debug-mini.flash {
animation: xbdbg-flash 0.35s ease-in-out 2;
}
@keyframes xbdbg-flash {
0%,100% { box-shadow: 0 4px 14px rgba(0,0,0,0.35); }
50% { box-shadow: 0 0 0 4px rgba(255,80,80,0.4); }
}
#xiaobaix-debug-panel {
position: fixed;
z-index: 10001;
background: rgba(22,22,26,0.97);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 10px;
box-shadow: 0 12px 36px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (min-width: 769px) {
#xiaobaix-debug-panel {
resize: both;
min-width: 320px;
min-height: 260px;
}
}
@media (max-width: 768px) {
#xiaobaix-debug-panel {
left: 0 !important;
right: 0 !important;
top: 0 !important;
width: 100% !important;
border-radius: 0;
resize: none;
}
}
#xiaobaix-debug-titlebar {
user-select: none;
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: rgba(30,30,34,0.98);
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
@media (min-width: 769px) {
#xiaobaix-debug-titlebar { cursor: move; }
}
#xiaobaix-debug-titlebar .left {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: rgba(255,255,255,0.88);
}
#xiaobaix-debug-titlebar .right {
display: flex;
align-items: center;
gap: 6px;
}
.xbdbg-btn {
width: 28px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.85);
cursor: pointer;
font-size: 12px;
transition: background 0.15s;
}
.xbdbg-btn:hover { background: rgba(255,255,255,0.12); }
#xiaobaix-debug-frame {
flex: 1;
border: 0;
width: 100%;
background: transparent;
}
`;
document.head.appendChild(style);
}
// ═══════════════════════════════════════════════════════════════════════════
// 定位计算
// ═══════════════════════════════════════════════════════════════════════════
function getAnchorRect() {
const anchor = document.getElementById("nonQRFormItems");
if (anchor) return anchor.getBoundingClientRect();
return { top: window.innerHeight - 60, right: window.innerWidth, left: 0, width: window.innerWidth };
}
function getDefaultMiniPos() {
const rect = getAnchorRect();
const btnW = 90, btnH = 32, margin = 8;
return { left: rect.right - btnW - margin, top: rect.top - btnH - margin };
}
function applyMiniPosition() {
if (!miniBtnEl) return;
const saved = readJSON(STORAGE_MINI_KEY);
const def = getDefaultMiniPos();
const pos = saved || def;
const w = miniBtnEl.offsetWidth || 90;
const h = miniBtnEl.offsetHeight || 32;
miniBtnEl.style.left = `${clamp(pos.left, 0, window.innerWidth - w)}px`;
miniBtnEl.style.top = `${clamp(pos.top, 0, window.innerHeight - h)}px`;
}
function saveMiniPos() {
if (!miniBtnEl) return;
const r = miniBtnEl.getBoundingClientRect();
writeJSON(STORAGE_MINI_KEY, { left: Math.round(r.left), top: Math.round(r.top) });
}
function applyExpandedPosition() {
if (!panelEl) return;
if (isMobile()) {
const rect = getAnchorRect();
panelEl.style.left = "0";
panelEl.style.top = "0";
panelEl.style.width = "100%";
panelEl.style.height = `${rect.top}px`;
return;
}
const saved = readJSON(STORAGE_EXPANDED_KEY);
const defW = 480, defH = 400;
const w = saved?.width >= 320 ? saved.width : defW;
const h = saved?.height >= 260 ? saved.height : defH;
const left = saved?.left != null ? clamp(saved.left, 0, window.innerWidth - w) : 20;
const top = saved?.top != null ? clamp(saved.top, 0, window.innerHeight - h) : 80;
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${w}px`;
panelEl.style.height = `${h}px`;
}
function saveExpandedPos() {
if (!panelEl || isMobile()) return;
const r = panelEl.getBoundingClientRect();
writeJSON(STORAGE_EXPANDED_KEY, { left: Math.round(r.left), top: Math.round(r.top), width: Math.round(r.width), height: Math.round(r.height) });
}
// ═══════════════════════════════════════════════════════════════════════════
// 数据获取与通信
// ═══════════════════════════════════════════════════════════════════════════
async function getDebugSnapshot() {
const { xbLog, CacheRegistry } = await import("../../core/debug-core.js");
const { EventCenter } = await import("../../core/event-manager.js");
const pageStats = getPageStats();
return {
logs: xbLog.getAll(),
events: EventCenter.getEventHistory?.() || [],
eventStatsDetail: EventCenter.statsDetail?.() || {},
caches: CacheRegistry.getStats(),
performance: {
requests: requestLog.slice(),
longTasks: longTaskLog.slice(),
fps: currentFps,
memory: getMemoryInfo(),
domCount: pageStats.domCount,
messageCount: pageStats.messageCount,
imageCount: pageStats.imageCount
}
};
}
function postToFrame(msg) {
try { iframeEl?.contentWindow?.postMessage({ source: "LittleWhiteBox-DebugHost", ...msg }, "*"); } catch {}
}
async function sendSnapshotToFrame() {
if (!frameReady) return;
const snapshot = await getDebugSnapshot();
postToFrame({ type: "XB_DEBUG_DATA", payload: snapshot });
updateMiniBadge(snapshot.logs);
}
async function handleAction(action) {
const { xbLog, CacheRegistry } = await import("../../core/debug-core.js");
const { EventCenter } = await import("../../core/event-manager.js");
switch (action?.action) {
case "refresh": await sendSnapshotToFrame(); break;
case "clearLogs": xbLog.clear(); await sendSnapshotToFrame(); break;
case "clearEvents": EventCenter.clearHistory?.(); await sendSnapshotToFrame(); break;
case "clearCache": if (action.moduleId) CacheRegistry.clear(action.moduleId); await sendSnapshotToFrame(); break;
case "clearAllCaches": CacheRegistry.clearAll(); await sendSnapshotToFrame(); break;
case "clearRequests": requestLog.length = 0; await sendSnapshotToFrame(); break;
case "clearTasks": longTaskLog.length = 0; await sendSnapshotToFrame(); break;
case "cacheDetail":
postToFrame({ type: "XB_DEBUG_CACHE_DETAIL", payload: { moduleId: action.moduleId, detail: CacheRegistry.getDetail(action.moduleId) } });
break;
case "exportLogs":
postToFrame({ type: "XB_DEBUG_EXPORT", payload: { text: xbLog.export() } });
break;
}
}
function bindMessageListener() {
if (messageListenerBound) return;
messageListenerBound = true;
window.addEventListener("message", async (e) => {
const msg = e?.data;
if (!msg || msg.source !== "LittleWhiteBox-DebugFrame") return;
if (msg.type === "FRAME_READY") { frameReady = true; await sendSnapshotToFrame(); }
else if (msg.type === "XB_DEBUG_ACTION") await handleAction(msg);
else if (msg.type === "CLOSE_PANEL") closeDebugPanel();
});
}
// ═══════════════════════════════════════════════════════════════════════════
// UI 更新
// ═══════════════════════════════════════════════════════════════════════════
function updateMiniBadge(logs) {
if (!miniBtnEl) return;
const badge = miniBtnEl.querySelector(".badge");
if (!badge) return;
const errCount = countErrors(logs);
badge.classList.toggle("hidden", errCount <= 0);
badge.textContent = errCount > 0 ? String(errCount) : "";
const newMax = maxLogId(logs);
if (newMax > lastLogId && !isExpanded) {
miniBtnEl.classList.remove("flash");
void miniBtnEl.offsetWidth;
miniBtnEl.classList.add("flash");
}
lastLogId = newMax;
}
function updateSettingsLight() {
const light = document.querySelector("#xiaobaix-debug-btn .dbg-light");
if (light) light.classList.toggle("on", isOpen);
}
// ═══════════════════════════════════════════════════════════════════════════
// 拖拽:最小化按钮
// ═══════════════════════════════════════════════════════════════════════════
function onMiniDown(e) {
if (e.button !== undefined && e.button !== 0) return;
dragState = {
startX: e.clientX, startY: e.clientY,
startLeft: miniBtnEl.getBoundingClientRect().left,
startTop: miniBtnEl.getBoundingClientRect().top,
pointerId: e.pointerId, moved: false
};
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
e.preventDefault();
}
function onMiniMove(e) {
if (!dragState || dragState.pointerId !== e.pointerId) return;
const dx = e.clientX - dragState.startX, dy = e.clientY - dragState.startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragState.moved = true;
const w = miniBtnEl.offsetWidth || 90, h = miniBtnEl.offsetHeight || 32;
miniBtnEl.style.left = `${clamp(dragState.startLeft + dx, 0, window.innerWidth - w)}px`;
miniBtnEl.style.top = `${clamp(dragState.startTop + dy, 0, window.innerHeight - h)}px`;
e.preventDefault();
}
function onMiniUp(e) {
if (!dragState || dragState.pointerId !== e.pointerId) return;
const wasMoved = dragState.moved;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
dragState = null;
saveMiniPos();
if (!wasMoved) expandPanel();
}
// ═══════════════════════════════════════════════════════════════════════════
// 拖拽:展开面板标题栏
// ═══════════════════════════════════════════════════════════════════════════
function onTitleDown(e) {
if (isMobile()) return;
if (e.button !== undefined && e.button !== 0) return;
if (e.target?.closest?.(".xbdbg-btn")) return;
dragState = {
startX: e.clientX, startY: e.clientY,
startLeft: panelEl.getBoundingClientRect().left,
startTop: panelEl.getBoundingClientRect().top,
pointerId: e.pointerId
};
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
e.preventDefault();
}
function onTitleMove(e) {
if (!dragState || isMobile() || dragState.pointerId !== e.pointerId) return;
const dx = e.clientX - dragState.startX, dy = e.clientY - dragState.startY;
const w = panelEl.offsetWidth, h = panelEl.offsetHeight;
panelEl.style.left = `${clamp(dragState.startLeft + dx, 0, window.innerWidth - w)}px`;
panelEl.style.top = `${clamp(dragState.startTop + dy, 0, window.innerHeight - h)}px`;
e.preventDefault();
}
function onTitleUp(e) {
if (!dragState || isMobile() || dragState.pointerId !== e.pointerId) return;
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
dragState = null;
saveExpandedPos();
}
// ═══════════════════════════════════════════════════════════════════════════
// 轮询与 resize
// ═══════════════════════════════════════════════════════════════════════════
function startPoll() {
stopPoll();
pollTimer = setInterval(async () => {
if (!isOpen) return;
try { await sendSnapshotToFrame(); } catch {}
}, 1500);
}
function stopPoll() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function onResize() {
if (!isOpen) return;
if (isExpanded) applyExpandedPosition();
else applyMiniPosition();
}
// ═══════════════════════════════════════════════════════════════════════════
// 面板生命周期
// ═══════════════════════════════════════════════════════════════════════════
function createMiniButton() {
if (miniBtnEl) return;
miniBtnEl = document.createElement("div");
miniBtnEl.id = "xiaobaix-debug-mini";
miniBtnEl.innerHTML = `<span>监控</span><span class="badge hidden"></span>`;
document.body.appendChild(miniBtnEl);
applyMiniPosition();
miniBtnEl.addEventListener("pointerdown", onMiniDown, { passive: false });
miniBtnEl.addEventListener("pointermove", onMiniMove, { passive: false });
miniBtnEl.addEventListener("pointerup", onMiniUp, { passive: false });
miniBtnEl.addEventListener("pointercancel", onMiniUp, { passive: false });
}
function removeMiniButton() {
miniBtnEl?.remove();
miniBtnEl = null;
}
function createPanel() {
if (panelEl) return;
panelEl = document.createElement("div");
panelEl.id = "xiaobaix-debug-panel";
const titlebar = document.createElement("div");
titlebar.id = "xiaobaix-debug-titlebar";
titlebar.innerHTML = `
<div class="left"><span>小白X 监控台</span></div>
<div class="right">
<button class="xbdbg-btn" id="xbdbg-min" title="最小化" type="button">—</button>
<button class="xbdbg-btn" id="xbdbg-close" title="关闭" type="button">×</button>
</div>
`;
iframeEl = document.createElement("iframe");
iframeEl.id = "xiaobaix-debug-frame";
iframeEl.src = `${extensionFolderPath}/modules/debug-panel/debug-panel.html`;
panelEl.appendChild(titlebar);
panelEl.appendChild(iframeEl);
document.body.appendChild(panelEl);
applyExpandedPosition();
titlebar.addEventListener("pointerdown", onTitleDown, { passive: false });
titlebar.addEventListener("pointermove", onTitleMove, { passive: false });
titlebar.addEventListener("pointerup", onTitleUp, { passive: false });
titlebar.addEventListener("pointercancel", onTitleUp, { passive: false });
panelEl.querySelector("#xbdbg-min")?.addEventListener("click", collapsePanel);
panelEl.querySelector("#xbdbg-close")?.addEventListener("click", closeDebugPanel);
if (!isMobile()) {
panelEl.addEventListener("mouseup", saveExpandedPos);
panelEl.addEventListener("mouseleave", saveExpandedPos);
}
frameReady = false;
}
function removePanel() {
panelEl?.remove();
panelEl = null;
iframeEl = null;
frameReady = false;
}
function expandPanel() {
if (isExpanded) return;
isExpanded = true;
if (miniBtnEl) miniBtnEl.style.display = "none";
if (panelEl) {
panelEl.style.display = "";
} else {
createPanel();
}
}
function collapsePanel() {
if (!isExpanded) return;
isExpanded = false;
saveExpandedPos();
if (panelEl) panelEl.style.display = "none";
if (miniBtnEl) {
miniBtnEl.style.display = "";
applyMiniPosition();
}
}
async function openDebugPanel() {
if (isOpen) return;
isOpen = true;
ensureStyle();
bindMessageListener();
const { enableDebugMode } = await import("../../core/debug-core.js");
enableDebugMode();
startPerfMonitor();
createMiniButton();
startPoll();
updateSettingsLight();
if (!resizeHandler) { resizeHandler = onResize; window.addEventListener("resize", resizeHandler); }
try { window.registerModuleCleanup?.("debugPanel", closeDebugPanel); } catch {}
}
async function closeDebugPanel() {
if (!isOpen) return;
isOpen = false;
isExpanded = false;
stopPoll();
stopPerfMonitor();
frameReady = false;
lastLogId = 0;
try { const { disableDebugMode } = await import("../../core/debug-core.js"); disableDebugMode(); } catch {}
removePanel();
removeMiniButton();
updateSettingsLight();
}
// ═══════════════════════════════════════════════════════════════════════════
// 导出
// ═══════════════════════════════════════════════════════════════════════════
export async function toggleDebugPanel() {
if (isOpen) await closeDebugPanel();
else await openDebugPanel();
}
export { openDebugPanel as openDebugPanelExplicit, closeDebugPanel as closeDebugPanelExplicit };
if (typeof window !== "undefined") {
window.xbDebugPanelToggle = toggleDebugPanel;
window.xbDebugPanelClose = closeDebugPanel;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

784
modules/iframe-renderer.js Normal file
View File

@@ -0,0 +1,784 @@
import { extension_settings, getContext } from "../../../../extensions.js";
import { createModuleEvents, event_types } from "../core/event-manager.js";
import { EXT_ID, extensionFolderPath } from "../core/constants.js";
import { xbLog, CacheRegistry } from "../core/debug-core.js";
import { replaceXbGetVarInString } from "./variables/var-commands.js";
import { executeSlashCommand } from "../core/slash-command.js";
import { default_user_avatar, default_avatar } from "../../../../../script.js";
const MODULE_ID = 'iframeRenderer';
const events = createModuleEvents(MODULE_ID);
let isGenerating = false;
const winMap = new Map();
let lastHeights = new WeakMap();
const blobUrls = new WeakMap();
const hashToBlobUrl = new Map();
const hashToBlobBytes = new Map();
const blobLRU = [];
const BLOB_CACHE_LIMIT = 32;
let lastApplyTs = 0;
let pendingHeight = null;
let pendingRec = null;
CacheRegistry.register(MODULE_ID, {
name: 'Blob URL 缓存',
getSize: () => hashToBlobUrl.size,
getBytes: () => {
let bytes = 0;
hashToBlobBytes.forEach(v => { bytes += Number(v) || 0; });
return bytes;
},
clear: () => {
clearBlobCaches();
hashToBlobBytes.clear();
},
getDetail: () => Array.from(hashToBlobUrl.keys()),
});
function getSettings() {
return extension_settings[EXT_ID] || {};
}
function djb2(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) + h) ^ str.charCodeAt(i);
}
return (h >>> 0).toString(16);
}
function shouldRenderContentByBlock(codeBlock) {
if (!codeBlock) return false;
const content = (codeBlock.textContent || '').trim().toLowerCase();
if (!content) return false;
return content.includes('<!doctype') || content.includes('<html') || content.includes('<script');
}
function generateUniqueId() {
return `xiaobaix-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function setIframeBlobHTML(iframe, fullHTML, codeHash) {
const existing = hashToBlobUrl.get(codeHash);
if (existing) {
iframe.src = existing;
blobUrls.set(iframe, existing);
return;
}
const blob = new Blob([fullHTML], { type: 'text/html' });
const url = URL.createObjectURL(blob);
iframe.src = url;
blobUrls.set(iframe, url);
hashToBlobUrl.set(codeHash, url);
try { hashToBlobBytes.set(codeHash, blob.size || 0); } catch {}
blobLRU.push(codeHash);
while (blobLRU.length > BLOB_CACHE_LIMIT) {
const old = blobLRU.shift();
const u = hashToBlobUrl.get(old);
hashToBlobUrl.delete(old);
hashToBlobBytes.delete(old);
try { URL.revokeObjectURL(u); } catch (e) {}
}
}
function releaseIframeBlob(iframe) {
try {
const url = blobUrls.get(iframe);
if (url) URL.revokeObjectURL(url);
blobUrls.delete(iframe);
} catch (e) {}
}
export function clearBlobCaches() {
try { xbLog.info(MODULE_ID, '清空 Blob 缓存'); } catch {}
hashToBlobUrl.forEach(u => { try { URL.revokeObjectURL(u); } catch {} });
hashToBlobUrl.clear();
hashToBlobBytes.clear();
blobLRU.length = 0;
}
function buildResourceHints(html) {
const urls = Array.from(new Set((html.match(/https?:\/\/[^"'()\s]+/gi) || [])
.map(u => { try { return new URL(u).origin; } catch { return null; } })
.filter(Boolean)));
let hints = "";
const maxHosts = 6;
for (let i = 0; i < Math.min(urls.length, maxHosts); i++) {
const origin = urls[i];
hints += `<link rel="dns-prefetch" href="${origin}">`;
hints += `<link rel="preconnect" href="${origin}" crossorigin>`;
}
let preload = "";
const font = (html.match(/https?:\/\/[^"'()\s]+\.(?:woff2|woff|ttf|otf)/i) || [])[0];
if (font) {
const type = font.endsWith(".woff2") ? "font/woff2" : font.endsWith(".woff") ? "font/woff" : font.endsWith(".ttf") ? "font/ttf" : "font/otf";
preload += `<link rel="preload" as="font" href="${font}" type="${type}" crossorigin fetchpriority="high">`;
}
const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0];
if (css) {
preload += `<link rel="preload" as="style" href="${css}" crossorigin fetchpriority="high">`;
}
const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0];
if (img) {
preload += `<link rel="preload" as="image" href="${img}" crossorigin fetchpriority="high">`;
}
return hints + preload;
}
function iframeClientScript() {
return `
(function(){
function measureVisibleHeight(){
try{
var doc = document;
var 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;
}
}
function post(m){ try{ parent.postMessage(m,'*') }catch(e){} }
var rafPending=false, lastH=0;
var 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){
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){
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, 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){}
})();`;
}
function buildWrappedHtml(html) {
const settings = getSettings();
const api = `<script>${iframeClientScript()}</script>`;
const wrapperToggle = settings.wrapperIframe ?? true;
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
const optWrapperUrl = `${origin}/scripts/extensions/third-party/${EXT_ID}/bridges/wrapper-iframe.js`;
const optWrapper = wrapperToggle ? `<script src="${optWrapperUrl}"></script>` : "";
const baseTag = settings.useBlob ? `<base href="${origin}/">` : "";
const headHints = buildResourceHints(html);
const vhFix = `<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}.profile-container,[style*="100vh"]{height:auto!important;min-height:600px!important}[style*="height:100%"]{height:auto!important;min-height:100%!important}</style>`;
if (html.includes('<html') && html.includes('</html')) {
if (html.includes('<head>'))
return html.replace('<head>', `<head>${baseTag}${api}${optWrapper}${headHints}${vhFix}`);
if (html.includes('</head>'))
return html.replace('</head>', `${baseTag}${api}${optWrapper}${headHints}${vhFix}</head>`);
return html.replace('<body', `<head>${baseTag}${api}${optWrapper}${headHints}${vhFix}</head><body`);
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${baseTag}
${api}
${optWrapper}
${headHints}
${vhFix}
<style>
html, body { margin: 0; padding: 0; background: transparent; }
</style>
</head>
<body>${html}</body></html>`;
}
function getOrCreateWrapper(preEl) {
let wrapper = preEl.previousElementSibling;
if (!wrapper || !wrapper.classList.contains('xiaobaix-iframe-wrapper')) {
wrapper = document.createElement('div');
wrapper.className = 'xiaobaix-iframe-wrapper';
wrapper.style.cssText = 'margin:0;';
preEl.parentNode.insertBefore(wrapper, preEl);
}
return wrapper;
}
function registerIframeMapping(iframe, wrapper) {
const tryMap = () => {
try {
if (iframe && iframe.contentWindow) {
winMap.set(iframe.contentWindow, { iframe, wrapper });
return true;
}
} catch (e) {}
return false;
};
if (tryMap()) return;
let tries = 0;
const t = setInterval(() => {
tries++;
if (tryMap() || tries > 20) clearInterval(t);
}, 25);
}
function resolveAvatarUrls() {
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
const toAbsUrl = (relOrUrl) => {
if (!relOrUrl) return '';
const s = String(relOrUrl);
if (/^(data:|blob:|https?:)/i.test(s)) return s;
if (s.startsWith('User Avatars/')) {
return `${origin}/${s}`;
}
const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/');
return `${origin}/${encoded.replace(/^\/+/, '')}`;
};
const pickSrc = (selectors) => {
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
const highRes = el.getAttribute('data-izoomify-url');
if (highRes) return highRes;
if (el.src) return el.src;
}
}
return '';
};
let user = pickSrc([
'#user_avatar_block img',
'#avatar_user img',
'.user_avatar img',
'img#avatar_user',
'.st-user-avatar img'
]) || default_user_avatar;
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
if (m) {
user = `User Avatars/${decodeURIComponent(m[1])}`;
}
const ctx = getContext?.() || {};
const chId = ctx.characterId ?? ctx.this_chid;
const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null;
let char = ch?.avatar || default_avatar;
if (char && !/^(data:|blob:|https?:)/i.test(char)) {
char = /[\/]/.test(char) ? char.replace(/^\/+/, '') : `characters/${char}`;
}
return { user: toAbsUrl(user), char: toAbsUrl(char) };
}
function handleIframeMessage(event) {
const data = event.data || {};
let rec = winMap.get(event.source);
if (!rec || !rec.iframe) {
const iframes = document.querySelectorAll('iframe.xiaobaix-iframe');
for (const iframe of iframes) {
if (iframe.contentWindow === event.source) {
rec = { iframe, wrapper: iframe.parentElement };
winMap.set(event.source, rec);
break;
}
}
}
if (rec && rec.iframe && typeof data.height === 'number') {
const next = Math.max(0, Number(data.height) || 0);
if (next < 1) return;
const prev = lastHeights.get(rec.iframe) || 0;
if (!data.force && Math.abs(next - prev) < 1) return;
if (data.force) {
lastHeights.set(rec.iframe, next);
requestAnimationFrame(() => { rec.iframe.style.height = `${next}px`; });
return;
}
pendingHeight = next;
pendingRec = rec;
const now = performance.now();
const dt = now - lastApplyTs;
if (dt >= 50) {
lastApplyTs = now;
const h = pendingHeight, r = pendingRec;
pendingHeight = null;
pendingRec = null;
lastHeights.set(r.iframe, h);
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
} else {
setTimeout(() => {
if (pendingRec && pendingHeight != null) {
lastApplyTs = performance.now();
const h = pendingHeight, r = pendingRec;
pendingHeight = null;
pendingRec = null;
lastHeights.set(r.iframe, h);
requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; });
}
}, Math.max(0, 50 - dt));
}
return;
}
if (data && data.type === 'runCommand') {
executeSlashCommand(data.command)
.then(result => event.source.postMessage({
source: 'xiaobaix-host',
type: 'commandResult',
id: data.id,
result
}, '*'))
.catch(err => event.source.postMessage({
source: 'xiaobaix-host',
type: 'commandError',
id: data.id,
error: err.message || String(err)
}, '*'));
return;
}
if (data && data.type === 'getAvatars') {
try {
const urls = resolveAvatarUrls();
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls }, '*');
} catch (e) {
event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls: { user: '', char: '' } }, '*');
}
return;
}
}
export function renderHtmlInIframe(htmlContent, container, preElement) {
const settings = getSettings();
try {
const originalHash = djb2(htmlContent);
if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') {
try {
htmlContent = replaceXbGetVarInString(htmlContent);
} catch (e) {
console.warn('xbgetvar 宏替换失败:', e);
}
}
const iframe = document.createElement('iframe');
iframe.id = generateUniqueId();
iframe.className = 'xiaobaix-iframe';
iframe.style.cssText = 'width:100%;border:none;background:transparent;overflow:hidden;height:0;margin:0;padding:0;display:block;contain:layout paint style;will-change:height;min-height:50px';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('scrolling', 'no');
iframe.loading = 'eager';
if (settings.sandboxMode) {
iframe.setAttribute('sandbox', 'allow-scripts');
}
const wrapper = getOrCreateWrapper(preElement);
wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => {
try { old.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(old);
old.remove();
});
const codeHash = djb2(htmlContent);
const full = buildWrappedHtml(htmlContent);
if (settings.useBlob) {
setIframeBlobHTML(iframe, full, codeHash);
} else {
iframe.srcdoc = full;
}
wrapper.appendChild(iframe);
preElement.classList.remove('xb-show');
preElement.style.display = 'none';
registerIframeMapping(iframe, wrapper);
try { iframe.contentWindow?.postMessage({ type: 'probe' }, '*'); } catch (e) {}
preElement.dataset.xbFinal = 'true';
preElement.dataset.xbHash = originalHash;
return iframe;
} catch (err) {
console.error('[iframeRenderer] 渲染失败:', err);
return null;
}
}
export function processCodeBlocks(messageElement, forceFinal = true) {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
try {
const codeBlocks = messageElement.querySelectorAll('pre > code');
const ctx = getContext();
const lastId = ctx.chat?.length - 1;
const mesEl = messageElement.closest('.mes');
const mesId = mesEl ? Number(mesEl.getAttribute('mesid')) : null;
if (isGenerating && mesId === lastId && !forceFinal) return;
codeBlocks.forEach(codeBlock => {
const preElement = codeBlock.parentElement;
const should = shouldRenderContentByBlock(codeBlock);
const html = codeBlock.textContent || '';
const hash = djb2(html);
const isFinal = preElement.dataset.xbFinal === 'true';
const same = preElement.dataset.xbHash === hash;
if (isFinal && same) return;
if (should) {
renderHtmlInIframe(html, preElement.parentNode, preElement);
} else {
preElement.classList.add('xb-show');
preElement.removeAttribute('data-xbfinal');
preElement.removeAttribute('data-xbhash');
preElement.style.display = '';
}
preElement.dataset.xiaobaixBound = 'true';
});
} catch (err) {
console.error('[iframeRenderer] processCodeBlocks 失败:', err);
}
}
export function processExistingMessages() {
const settings = getSettings();
if (!settings.enabled) return;
document.querySelectorAll('.mes_text').forEach(el => processCodeBlocks(el, true));
try { shrinkRenderedWindowFull(); } catch (e) {}
}
export function processMessageById(messageId, forceFinal = true) {
const messageElement = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
if (!messageElement) return;
processCodeBlocks(messageElement, forceFinal);
try { shrinkRenderedWindowForLastMessage(); } catch (e) {}
}
export function invalidateMessage(messageId) {
const el = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`);
if (!el) return;
el.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
el.querySelectorAll('pre').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
pre.style.display = '';
delete pre.dataset.xiaobaixBound;
});
}
export function invalidateAll() {
document.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
document.querySelectorAll('.mes_text pre').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
clearBlobCaches();
winMap.clear();
lastHeights = new WeakMap();
}
function shrinkRenderedWindowForLastMessage() {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
? settings.maxRenderedMessages
: 0;
if (max <= 0) return;
const ctx = getContext?.();
const chatArr = ctx?.chat;
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
const lastId = chatArr.length - 1;
if (lastId < 0) return;
const keepFrom = Math.max(0, lastId - max + 1);
const mesList = document.querySelectorAll('div.mes');
for (const mes of mesList) {
const mesIdAttr = mes.getAttribute('mesid');
if (mesIdAttr == null) continue;
const mesId = Number(mesIdAttr);
if (!Number.isFinite(mesId)) continue;
if (mesId >= keepFrom) break;
const mesText = mes.querySelector('.mes_text');
if (!mesText) continue;
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
}
}
function shrinkRenderedWindowFull() {
const settings = getSettings();
if (!settings.enabled) return;
if (settings.renderEnabled === false) return;
const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0
? settings.maxRenderedMessages
: 0;
if (max <= 0) return;
const ctx = getContext?.();
const chatArr = ctx?.chat;
if (!Array.isArray(chatArr) || chatArr.length === 0) return;
const lastId = chatArr.length - 1;
const keepFrom = Math.max(0, lastId - max + 1);
const mesList = document.querySelectorAll('div.mes');
for (const mes of mesList) {
const mesIdAttr = mes.getAttribute('mesid');
if (mesIdAttr == null) continue;
const mesId = Number(mesIdAttr);
if (!Number.isFinite(mesId)) continue;
if (mesId >= keepFrom) continue;
const mesText = mes.querySelector('.mes_text');
if (!mesText) continue;
mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => {
w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => {
try { ifr.src = 'about:blank'; } catch (e) {}
releaseIframeBlob(ifr);
});
w.remove();
});
mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => {
pre.classList.remove('xb-show');
pre.removeAttribute('data-xbfinal');
pre.removeAttribute('data-xbhash');
delete pre.dataset.xbFinal;
delete pre.dataset.xbHash;
delete pre.dataset.xiaobaixBound;
pre.style.display = '';
});
}
}
let messageListenerBound = false;
export function initRenderer() {
try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {}
events.on(event_types.GENERATION_STARTED, () => {
isGenerating = true;
});
events.on(event_types.GENERATION_ENDED, () => {
isGenerating = false;
const ctx = getContext();
const lastId = ctx.chat?.length - 1;
if (lastId != null && lastId >= 0) {
setTimeout(() => {
processMessageById(lastId, true);
}, 60);
}
});
events.on(event_types.MESSAGE_RECEIVED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 300);
});
events.on(event_types.MESSAGE_UPDATED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
});
events.on(event_types.MESSAGE_EDITED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
});
events.on(event_types.MESSAGE_DELETED, (data) => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
invalidateMessage(messageId);
}
});
events.on(event_types.MESSAGE_SWIPED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.USER_MESSAGE_RENDERED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => {
setTimeout(() => {
const messageId = typeof data === 'object' ? data.messageId : data;
if (messageId != null) {
processMessageById(messageId, true);
}
}, 10);
});
events.on(event_types.CHAT_CHANGED, () => {
isGenerating = false;
invalidateAll();
setTimeout(() => {
processExistingMessages();
}, 100);
});
if (!messageListenerBound) {
window.addEventListener('message', handleIframeMessage);
messageListenerBound = true;
}
setTimeout(processExistingMessages, 100);
}
export function cleanupRenderer() {
try { xbLog.info(MODULE_ID, 'cleanupRenderer'); } catch {}
events.cleanup();
if (messageListenerBound) {
window.removeEventListener('message', handleIframeMessage);
messageListenerBound = false;
}
invalidateAll();
isGenerating = false;
pendingHeight = null;
pendingRec = null;
lastApplyTs = 0;
}
export function isCurrentlyGenerating() {
return isGenerating;
}
export { shrinkRenderedWindowFull, shrinkRenderedWindowForLastMessage };

473
modules/immersive-mode.js Normal file
View File

@@ -0,0 +1,473 @@
import { extension_settings, getContext } from "../../../../extensions.js";
import { saveSettingsDebounced, this_chid, getCurrentChatId } from "../../../../../script.js";
import { selected_group } from "../../../../group-chats.js";
import { EXT_ID } from "../core/constants.js";
import { createModuleEvents, event_types } from "../core/event-manager.js";
const defaultSettings = {
enabled: false,
showAllMessages: false,
autoJumpOnAI: true
};
const SEL = {
chat: '#chat',
mes: '#chat .mes',
ai: '#chat .mes[is_user="false"][is_system="false"]',
user: '#chat .mes[is_user="true"]'
};
const baseEvents = createModuleEvents('immersiveMode');
const messageEvents = createModuleEvents('immersiveMode:messages');
let state = {
isActive: false,
eventsBound: false,
messageEventsBound: false,
globalStateHandler: null
};
let observer = null;
let resizeObs = null;
let resizeObservedEl = null;
let recalcT = null;
const isGlobalEnabled = () => window.isXiaobaixEnabled ?? true;
const getSettings = () => extension_settings[EXT_ID].immersive;
const isInChat = () => this_chid !== undefined || selected_group || getCurrentChatId() !== undefined;
function initImmersiveMode() {
initSettings();
setupEventListeners();
if (isGlobalEnabled()) {
state.isActive = getSettings().enabled;
if (state.isActive) enableImmersiveMode();
bindSettingsEvents();
}
}
function initSettings() {
extension_settings[EXT_ID] ||= {};
extension_settings[EXT_ID].immersive ||= structuredClone(defaultSettings);
const settings = extension_settings[EXT_ID].immersive;
Object.keys(defaultSettings).forEach(k => settings[k] = settings[k] ?? defaultSettings[k]);
updateControlState();
}
function setupEventListeners() {
state.globalStateHandler = handleGlobalStateChange;
baseEvents.on(event_types.CHAT_CHANGED, onChatChanged);
document.addEventListener('xiaobaixEnabledChanged', state.globalStateHandler);
if (window.registerModuleCleanup) window.registerModuleCleanup('immersiveMode', cleanup);
}
function setupDOMObserver() {
if (observer) return;
const chatContainer = document.getElementById('chat');
if (!chatContainer) return;
observer = new MutationObserver((mutations) => {
if (!state.isActive) return;
let hasNewAI = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes?.length) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.classList?.contains('mes')) {
processSingleMessage(node);
if (node.getAttribute('is_user') === 'false' && node.getAttribute('is_system') === 'false') {
hasNewAI = true;
}
}
});
}
}
if (hasNewAI) {
if (recalcT) clearTimeout(recalcT);
recalcT = setTimeout(updateMessageDisplay, 20);
}
});
observer.observe(chatContainer, { childList: true, subtree: true, characterData: true });
}
function processSingleMessage(mesElement) {
const $mes = $(mesElement);
const $avatarWrapper = $mes.find('.mesAvatarWrapper');
const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween');
const $targetSibling = $chName.find('.flex-container.flex1.alignitemscenter');
const $nameText = $mes.find('.name_text');
if ($avatarWrapper.length && $chName.length && $targetSibling.length &&
!$chName.find('.mesAvatarWrapper').length) {
$targetSibling.before($avatarWrapper);
if ($nameText.length && !$nameText.parent().hasClass('xiaobaix-vertical-wrapper')) {
const $verticalWrapper = $('<div class="xiaobaix-vertical-wrapper" style="display: flex; flex-direction: column; flex: 1; margin-top: 5px; align-self: stretch; justify-content: space-between;"></div>');
const $topGroup = $('<div class="xiaobaix-top-group"></div>');
$topGroup.append($nameText.detach(), $targetSibling.detach());
$verticalWrapper.append($topGroup);
$avatarWrapper.after($verticalWrapper);
}
}
}
function updateControlState() {
const enabled = isGlobalEnabled();
$('#xiaobaix_immersive_enabled').prop('disabled', !enabled).toggleClass('disabled-control', !enabled);
}
function bindSettingsEvents() {
if (state.eventsBound) return;
setTimeout(() => {
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
if (checkbox && !state.eventsBound) {
checkbox.checked = getSettings().enabled;
checkbox.addEventListener('change', () => setImmersiveMode(checkbox.checked));
state.eventsBound = true;
}
}, 500);
}
function unbindSettingsEvents() {
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
if (checkbox) {
const newCheckbox = checkbox.cloneNode(true);
checkbox.parentNode.replaceChild(newCheckbox, checkbox);
}
state.eventsBound = false;
}
function setImmersiveMode(enabled) {
const settings = getSettings();
settings.enabled = enabled;
state.isActive = enabled;
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
if (checkbox) checkbox.checked = enabled;
enabled ? enableImmersiveMode() : disableImmersiveMode();
if (!enabled) cleanup();
saveSettingsDebounced();
}
function toggleImmersiveMode() {
if (!isGlobalEnabled()) return;
setImmersiveMode(!getSettings().enabled);
}
function bindMessageEvents() {
if (state.messageEventsBound) return;
const refreshOnAI = () => state.isActive && updateMessageDisplay();
messageEvents.on(event_types.MESSAGE_SENT, () => {});
messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI);
messageEvents.on(event_types.MESSAGE_DELETED, () => {});
messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI);
messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI);
if (event_types.GENERATION_STARTED) {
messageEvents.on(event_types.GENERATION_STARTED, () => {});
}
messageEvents.on(event_types.GENERATION_ENDED, refreshOnAI);
state.messageEventsBound = true;
}
function unbindMessageEvents() {
if (!state.messageEventsBound) return;
messageEvents.cleanup();
state.messageEventsBound = false;
}
function injectImmersiveStyles() {
let style = document.getElementById('immersive-style-tag');
if (!style) {
style = document.createElement('style');
style.id = 'immersive-style-tag';
document.head.appendChild(style);
}
style.textContent = `
body.immersive-mode.immersive-single #show_more_messages { display: none !important; }
`;
}
function applyModeClasses() {
const settings = getSettings();
$('body')
.toggleClass('immersive-single', !settings.showAllMessages)
.toggleClass('immersive-all', settings.showAllMessages);
}
function enableImmersiveMode() {
if (!isGlobalEnabled()) return;
injectImmersiveStyles();
$('body').addClass('immersive-mode');
applyModeClasses();
moveAvatarWrappers();
bindMessageEvents();
updateMessageDisplay();
setupDOMObserver();
}
function disableImmersiveMode() {
$('body').removeClass('immersive-mode immersive-single immersive-all');
restoreAvatarWrappers();
$(SEL.mes).show();
hideNavigationButtons();
$('.swipe_left, .swipeRightBlock').show();
unbindMessageEvents();
detachResizeObserver();
destroyDOMObserver();
}
function moveAvatarWrappers() {
$(SEL.mes).each(function() { processSingleMessage(this); });
}
function restoreAvatarWrappers() {
$(SEL.mes).each(function() {
const $mes = $(this);
const $avatarWrapper = $mes.find('.mesAvatarWrapper');
const $verticalWrapper = $mes.find('.xiaobaix-vertical-wrapper');
if ($avatarWrapper.length && !$avatarWrapper.parent().hasClass('mes')) {
$mes.prepend($avatarWrapper);
}
if ($verticalWrapper.length) {
const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween');
const $flexContainer = $mes.find('.flex-container.flex1.alignitemscenter');
const $nameText = $mes.find('.name_text');
if ($flexContainer.length && $chName.length) $chName.prepend($flexContainer);
if ($nameText.length) {
const $originalContainer = $mes.find('.flex-container.alignItemsBaseline');
if ($originalContainer.length) $originalContainer.prepend($nameText);
}
$verticalWrapper.remove();
}
});
}
function findLastAIMessage() {
const $aiMessages = $(SEL.ai);
return $aiMessages.length ? $($aiMessages.last()) : null;
}
function showSingleModeMessages() {
const $messages = $(SEL.mes);
if (!$messages.length) return;
$messages.hide();
const $targetAI = findLastAIMessage();
if ($targetAI?.length) {
$targetAI.show();
const $prevUser = $targetAI.prevAll('.mes[is_user="true"]').first();
if ($prevUser.length) {
$prevUser.show();
}
$targetAI.nextAll('.mes').show();
addNavigationToLastTwoMessages();
}
}
function addNavigationToLastTwoMessages() {
hideNavigationButtons();
const $visibleMessages = $(`${SEL.mes}:visible`);
const messageCount = $visibleMessages.length;
if (messageCount >= 2) {
const $lastTwo = $visibleMessages.slice(-2);
$lastTwo.each(function() {
showNavigationButtons($(this));
updateSwipesCounter($(this));
});
} else if (messageCount === 1) {
const $single = $visibleMessages.last();
showNavigationButtons($single);
updateSwipesCounter($single);
}
}
function updateMessageDisplay() {
if (!state.isActive) return;
const $messages = $(SEL.mes);
if (!$messages.length) return;
const settings = getSettings();
if (settings.showAllMessages) {
$messages.show();
addNavigationToLastTwoMessages();
} else {
showSingleModeMessages();
}
}
function showNavigationButtons($targetMes) {
if (!isInChat()) return;
$targetMes.find('.immersive-navigation').remove();
const $verticalWrapper = $targetMes.find('.xiaobaix-vertical-wrapper');
if (!$verticalWrapper.length) return;
const settings = getSettings();
const buttonText = settings.showAllMessages ? '切换:锁定单回合' : '切换:传统多楼层';
const navigationHtml = `
<div class="immersive-navigation">
<button class="immersive-nav-btn immersive-swipe-left" title="左滑消息">
<i class="fa-solid fa-chevron-left"></i>
</button>
<button class="immersive-nav-btn immersive-toggle" title="切换显示模式">
|${buttonText}|
</button>
<button class="immersive-nav-btn immersive-swipe-right" title="右滑消息"
style="display: flex; align-items: center; gap: 1px;">
<div class="swipes-counter" style="opacity: 0.7; justify-content: flex-end; margin-bottom: 0 !important;">
1&ZeroWidthSpace;/&ZeroWidthSpace;1
</div>
<span><i class="fa-solid fa-chevron-right"></i></span>
</button>
</div>
`;
$verticalWrapper.append(navigationHtml);
$targetMes.find('.immersive-swipe-left').on('click', () => handleSwipe('.swipe_left', $targetMes));
$targetMes.find('.immersive-toggle').on('click', toggleDisplayMode);
$targetMes.find('.immersive-swipe-right').on('click', () => handleSwipe('.swipe_right', $targetMes));
}
const hideNavigationButtons = () => $('.immersive-navigation').remove();
function updateSwipesCounter($targetMes) {
if (!state.isActive) return;
const $swipesCounter = $targetMes.find('.swipes-counter');
if (!$swipesCounter.length) return;
const mesId = $targetMes.attr('mesid');
if (mesId !== undefined) {
try {
const chat = getContext().chat;
const mesIndex = parseInt(mesId);
const message = chat?.[mesIndex];
if (message?.swipes) {
const currentSwipeIndex = message.swipe_id || 0;
$swipesCounter.html(`${currentSwipeIndex + 1}&ZeroWidthSpace;/&ZeroWidthSpace;${message.swipes.length}`);
return;
}
} catch {}
}
$swipesCounter.html('1&ZeroWidthSpace;/&ZeroWidthSpace;1');
}
function toggleDisplayMode() {
if (!state.isActive) return;
const settings = getSettings();
settings.showAllMessages = !settings.showAllMessages;
applyModeClasses();
updateMessageDisplay();
saveSettingsDebounced();
}
function handleSwipe(swipeSelector, $targetMes) {
if (!state.isActive) return;
const $btn = $targetMes.find(swipeSelector);
if ($btn.length) {
$btn.click();
setTimeout(() => {
updateSwipesCounter($targetMes);
}, 100);
}
}
function handleGlobalStateChange(event) {
const enabled = event.detail.enabled;
updateControlState();
if (enabled) {
const settings = getSettings();
state.isActive = settings.enabled;
if (state.isActive) enableImmersiveMode();
bindSettingsEvents();
setTimeout(() => {
const checkbox = document.getElementById('xiaobaix_immersive_enabled');
if (checkbox) checkbox.checked = settings.enabled;
}, 100);
} else {
if (state.isActive) disableImmersiveMode();
state.isActive = false;
unbindSettingsEvents();
}
}
function onChatChanged() {
if (!isGlobalEnabled() || !state.isActive) return;
setTimeout(() => {
moveAvatarWrappers();
updateMessageDisplay();
}, 100);
}
function cleanup() {
if (state.isActive) disableImmersiveMode();
destroyDOMObserver();
baseEvents.cleanup();
if (state.globalStateHandler) {
document.removeEventListener('xiaobaixEnabledChanged', state.globalStateHandler);
}
unbindMessageEvents();
detachResizeObserver();
state = {
isActive: false,
eventsBound: false,
messageEventsBound: false,
globalStateHandler: null
};
}
function attachResizeObserverTo(el) {
if (!el) return;
if (!resizeObs) {
resizeObs = new ResizeObserver(() => {});
}
if (resizeObservedEl) detachResizeObserver();
resizeObservedEl = el;
resizeObs.observe(el);
}
function detachResizeObserver() {
if (resizeObs && resizeObservedEl) {
resizeObs.unobserve(resizeObservedEl);
}
resizeObservedEl = null;
}
function destroyDOMObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
}
export { initImmersiveMode, toggleImmersiveMode };

650
modules/message-preview.js Normal file
View File

@@ -0,0 +1,650 @@
import { extension_settings, getContext } from "../../../../extensions.js";
import { saveSettingsDebounced, eventSource, event_types } from "../../../../../script.js";
import { EXT_ID } from "../core/constants.js";
const C = { MAX_HISTORY: 10, CHECK: 200, DEBOUNCE: 300, CLEAN: 300000, TARGET: "/api/backends/chat-completions/generate", TIMEOUT: 30, ASSOC_DELAY: 1000, REQ_WINDOW: 30000 };
const S = { active: false, isPreview: false, isLong: false, isHistoryUiBound: false, previewData: null, previewIds: new Set(), interceptedIds: [], history: [], listeners: [], resolve: null, reject: null, sendBtnWasDisabled: false, longPressTimer: null, longPressDelay: 1000, chatLenBefore: 0, restoreLong: null, cleanTimer: null, previewAbort: null, tailAPI: null, genEndedOff: null, cleanupFallback: null, pendingPurge: false };
const $q = (sel) => $(sel);
const ON = (e, c) => eventSource.on(e, c);
const OFF = (e, c) => eventSource.removeListener(e, c);
const now = () => Date.now();
const geEnabled = () => { try { return ("isXiaobaixEnabled" in window) ? !!window.isXiaobaixEnabled : true; } catch { return true; } };
const debounce = (fn, w) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), w); }; };
const safeJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
const readText = async (b) => { try { if (!b) return ""; if (typeof b === "string") return b; if (b instanceof Blob) return await b.text(); if (b instanceof URLSearchParams) return b.toString(); if (typeof b === "object" && typeof b.text === "function") return await b.text(); } catch { } return ""; };
function isSafeBody(body) { if (!body) return true; return (typeof body === "string" || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof FormData !== "undefined" && body instanceof FormData)); }
async function safeReadBodyFromInput(input, options) { try { if (input instanceof Request) return await readText(input.clone()); const body = options?.body; if (!isSafeBody(body)) return ""; return await readText(body); } catch { return ""; } }
const isGen = (u) => String(u || "").includes(C.TARGET);
const isTarget = async (input, opt = {}) => { try { const url = input instanceof Request ? input.url : input; if (!isGen(url)) return false; const text = await safeReadBodyFromInput(input, opt); return text ? text.includes('"messages"') : true; } catch { return input instanceof Request ? isGen(input.url) : isGen(input); } };
const getSettings = () => { const d = extension_settings[EXT_ID] || (extension_settings[EXT_ID] = {}); d.preview = d.preview || { enabled: false, timeoutSeconds: C.TIMEOUT }; d.recorded = d.recorded || { enabled: true }; d.preview.timeoutSeconds = C.TIMEOUT; return d; };
function injectPreviewModalStyles() {
if (document.getElementById('message-preview-modal-styles')) return;
const style = document.createElement('style');
style.id = 'message-preview-modal-styles';
style.textContent = `
.mp-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none}
.mp-modal{
width:clamp(360px,55vw,860px);
max-width:95vw;
background:var(--SmartThemeBlurTintColor);
border:2px solid var(--SmartThemeBorderColor);
border-radius:10px;
box-shadow:0 8px 16px var(--SmartThemeShadowColor);
pointer-events:auto;
display:flex;
flex-direction:column;
height:80vh;
max-height:calc(100vh - 60px);
resize:both;
overflow:hidden;
}
.mp-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move;flex-shrink:0}
.mp-body{height:60vh;overflow:auto;padding:10px;flex:1;min-height:160px}
.mp-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor);flex-shrink:0}
.mp-close{cursor:pointer}
.mp-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px}
.mp-search-input{padding:4px 8px;border:1px solid var(--SmartThemeBorderColor);border-radius:4px;background:var(--SmartThemeShadowColor);color:inherit;font-size:12px;width:120px}
.mp-search-btn{padding:4px 6px;font-size:12px;min-width:24px;text-align:center}
.mp-search-info{font-size:12px;opacity:.8;white-space:nowrap}
.message-preview-container{height:100%}
.message-preview-content-box{height:100%;overflow:auto}
.mp-highlight{background-color:yellow;color:black;padding:1px 2px;border-radius:2px}
.mp-highlight.current{background-color:orange;font-weight:bold}
@media (max-width:999px){
.mp-overlay{position:absolute;inset:0;align-items:flex-start}
.mp-modal{width:100%;max-width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0;height:100vh;resize:none}
.mp-header{padding:8px 14px}
.mp-body{padding:8px}
.mp-footer{padding:8px 14px;flex-wrap:wrap;gap:6px}
.mp-search-input{width:150px}
}
`;
document.head.appendChild(style);
}
function setupModalDrag(modal, overlay, header) {
modal.style.position = 'absolute';
modal.style.left = '50%';
modal.style.top = '50%';
modal.style.transform = 'translate(-50%, -50%)';
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
function onDown(e) {
if (!(e instanceof PointerEvent) || e.button !== 0) return;
dragging = true;
const overlayRect = overlay.getBoundingClientRect();
const rect = modal.getBoundingClientRect();
modal.style.left = (rect.left - overlayRect.left) + 'px';
modal.style.top = (rect.top - overlayRect.top) + 'px';
modal.style.transform = '';
sx = e.clientX; sy = e.clientY;
sl = parseFloat(modal.style.left) || 0;
st = parseFloat(modal.style.top) || 0;
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerup', onUp, { once: true });
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
const dx = e.clientX - sx, dy = e.clientY - sy;
let nl = sl + dx, nt = st + dy;
const maxLeft = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth;
const maxTop = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight;
nl = Math.max(0, Math.min(maxLeft, nl));
nt = Math.max(0, Math.min(maxTop, nt));
modal.style.left = nl + 'px';
modal.style.top = nt + 'px';
}
function onUp() {
dragging = false;
window.removeEventListener('pointermove', onMove);
}
header.addEventListener('pointerdown', onDown);
}
function createMovableModal(title, content) {
injectPreviewModalStyles();
const overlay = document.createElement('div');
overlay.className = 'mp-overlay';
const modal = document.createElement('div');
modal.className = 'mp-modal';
const header = document.createElement('div');
header.className = 'mp-header';
header.innerHTML = `<span>${title}</span><span class="mp-close">✕</span>`;
const body = document.createElement('div');
body.className = 'mp-body';
body.innerHTML = content;
const footer = document.createElement('div');
footer.className = 'mp-footer';
footer.innerHTML = `
<input type="text" class="mp-search-input" placeholder="搜索..." />
<button class="mp-btn mp-search-btn" id="mp-search-prev">↑</button>
<button class="mp-btn mp-search-btn" id="mp-search-next">↓</button>
<span class="mp-search-info" id="mp-search-info"></span>
<button class="mp-btn" id="mp-toggle-format">切换原始格式</button>
<button class="mp-btn" id="mp-focus-search">搜索</button>
<button class="mp-btn" id="mp-close">关闭</button>
`;
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
setupModalDrag(modal, overlay, header);
let searchResults = [];
let currentIndex = -1;
const searchInput = footer.querySelector('.mp-search-input');
const searchInfo = footer.querySelector('#mp-search-info');
const prevBtn = footer.querySelector('#mp-search-prev');
const nextBtn = footer.querySelector('#mp-search-next');
function clearHighlights() { body.querySelectorAll('.mp-highlight').forEach(el => { el.outerHTML = el.innerHTML; }); }
function performSearch(query) {
clearHighlights();
searchResults = [];
currentIndex = -1;
if (!query.trim()) { searchInfo.textContent = ''; return; }
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false);
const nodes = [];
let node;
while (node = walker.nextNode()) { nodes.push(node); }
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
nodes.forEach(textNode => {
const text = textNode.textContent;
if (!text || !regex.test(text)) return;
let html = text;
let offset = 0;
regex.lastIndex = 0;
const matches = [...text.matchAll(regex)];
matches.forEach((m) => {
const start = m.index + offset;
const end = start + m[0].length;
const before = html.slice(0, start);
const mid = html.slice(start, end);
const after = html.slice(end);
const span = `<span class="mp-highlight" data-search-index="${searchResults.length}">${mid}</span>`;
html = before + span + after;
offset += span.length - m[0].length;
searchResults.push({});
});
const parent = textNode.parentElement;
parent.innerHTML = parent.innerHTML.replace(text, html);
});
updateSearchInfo();
if (searchResults.length > 0) { currentIndex = 0; highlightCurrent(); }
}
function updateSearchInfo() { if (!searchResults.length) searchInfo.textContent = searchInput.value.trim() ? '无结果' : ''; else searchInfo.textContent = `${currentIndex + 1}/${searchResults.length}`; }
function highlightCurrent() {
body.querySelectorAll('.mp-highlight.current').forEach(el => el.classList.remove('current'));
if (currentIndex >= 0 && currentIndex < searchResults.length) {
const el = body.querySelector(`.mp-highlight[data-search-index="${currentIndex}"]`);
if (el) { el.classList.add('current'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
}
}
function navigateSearch(direction) {
if (!searchResults.length) return;
if (direction === 'next') currentIndex = (currentIndex + 1) % searchResults.length;
else currentIndex = currentIndex <= 0 ? searchResults.length - 1 : currentIndex - 1;
updateSearchInfo();
highlightCurrent();
}
let searchTimeout;
searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => performSearch(e.target.value), 250); });
searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) navigateSearch('prev'); else navigateSearch('next'); } else if (e.key === 'Escape') { searchInput.value = ''; performSearch(''); } });
prevBtn.addEventListener('click', () => navigateSearch('prev'));
nextBtn.addEventListener('click', () => navigateSearch('next'));
footer.querySelector('#mp-focus-search')?.addEventListener('click', () => { searchInput.focus(); if (searchInput.value) navigateSearch('next'); });
const close = () => overlay.remove();
header.querySelector('.mp-close').addEventListener('click', close);
footer.querySelector('#mp-close').addEventListener('click', close);
footer.querySelector('#mp-toggle-format').addEventListener('click', (e) => {
const box = body.querySelector(".message-preview-content-box");
const f = box?.querySelector(".mp-state-formatted");
const r = box?.querySelector(".mp-state-raw");
if (!(f && r)) return;
const showRaw = r.style.display === "none";
r.style.display = showRaw ? "block" : "none";
f.style.display = showRaw ? "none" : "block";
e.currentTarget.textContent = showRaw ? "切换整理格式" : "切换原始格式";
searchInput.value = "";
clearHighlights();
searchInfo.textContent = "";
searchResults = [];
currentIndex = -1;
});
document.body.appendChild(overlay);
return { overlay, modal, body, close };
}
const MIRROR = { MERGE: "merge", MERGE_TOOLS: "merge_tools", SEMI: "semi", SEMI_TOOLS: "semi_tools", STRICT: "strict", STRICT_TOOLS: "strict_tools", SINGLE: "single" };
const roleMap = { system: { label: "SYSTEM:", color: "#F7E3DA" }, user: { label: "USER:", color: "#F0ADA7" }, assistant: { label: "ASSISTANT:", color: "#6BB2CC" } };
const colorXml = (t) => (typeof t === "string" ? t.replace(/<([^>]+)>/g, '<span style="color:#999;font-weight:bold;">&lt;$1&gt;</span>') : t);
const getNames = (req) => { const n = { charName: String(req?.char_name || ""), userName: String(req?.user_name || ""), groupNames: Array.isArray(req?.group_names) ? req.group_names.map(String) : [] }; n.startsWithGroupName = (m) => n.groupNames.some((g) => String(m || "").startsWith(`${g}: `)); return n; };
const toText = (m) => { const c = m?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c.map((p) => p?.type === "text" ? String(p.text || "") : p?.type === "image_url" ? "[image]" : p?.type === "video_url" ? "[video]" : typeof p === "string" ? p : (typeof p?.content === "string" ? p.content : "")).filter(Boolean).join("\n\n"); return String(c || ""); };
const applyName = (m, n) => { const { role, name } = m; let t = toText(m); if (role === "system" && name === "example_assistant") { if (n.charName && !t.startsWith(`${n.charName}: `) && !n.startsWithGroupName(t)) t = `${n.charName}: ${t}`; } else if (role === "system" && name === "example_user") { if (n.userName && !t.startsWith(`${n.userName}: `)) t = `${n.userName}: ${t}`; } else if (name && role !== "system" && !t.startsWith(`${name}: `)) t = `${name}: ${t}`; return { ...m, content: t, name: undefined }; };
function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) {
if (!Array.isArray(messages)) return [];
let mapped = messages.map((m) => applyName({ ...m }, names)).map((x) => { const m = { ...x }; if (!tools) { if (m.role === "tool") m.role = "user"; delete m.tool_calls; delete m.tool_call_id; } if (single) { if (m.role === "assistant") { const t = String(m.content || ""); if (names.charName && !t.startsWith(`${names.charName}: `) && !names.startsWithGroupName(t)) m.content = `${names.charName}: ${t}`; } if (m.role === "user") { const t = String(m.content || ""); if (names.userName && !t.startsWith(`${names.userName}: `)) m.content = `${names.userName}: ${t}`; } m.role = "user"; } return m; });
const squash = (arr) => { const out = []; for (const m of arr) { if (out.length && out[out.length - 1].role === m.role && String(m.content || "").length && m.role !== "tool") out[out.length - 1].content += `\n\n${m.content}`; else out.push(m); } return out; };
let sq = squash(mapped);
if (strict) { for (let i = 0; i < sq.length; i++) if (i > 0 && sq[i].role === "system") sq[i].role = "user"; if (placeholders) { if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); else if (sq[0].role === "system" && (sq.length === 1 || sq[1].role !== "user")) sq.splice(1, 0, { role: "user", content: "[Start a new chat]" }); else if (sq[0].role !== "system" && sq[0].role !== "user") sq.unshift({ role: "user", content: "[Start a new chat]" }); } return squash(sq); }
if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" });
return sq;
}
function mirror(requestData) {
try {
let type = String(requestData?.custom_prompt_post_processing || "").toLowerCase();
const source = String(requestData?.chat_completion_source || "").toLowerCase();
if (source === "perplexity") type = MIRROR.STRICT;
const names = getNames(requestData || {}), src = Array.isArray(requestData?.messages) ? JSON.parse(JSON.stringify(requestData.messages)) : [];
const mk = (o) => mergeMessages(src, names, o);
switch (type) {
case MIRROR.MERGE: return mk({ strict: false });
case MIRROR.MERGE_TOOLS: return mk({ strict: false, tools: true });
case MIRROR.SEMI: return mk({ strict: true });
case MIRROR.SEMI_TOOLS: return mk({ strict: true, tools: true });
case MIRROR.STRICT: return mk({ strict: true, placeholders: true });
case MIRROR.STRICT_TOOLS: return mk({ strict: true, placeholders: true, tools: true });
case MIRROR.SINGLE: return mk({ strict: true, single: true });
default: return src;
}
} catch { return Array.isArray(requestData?.messages) ? requestData.messages : []; }
}
const finalMsgs = (d) => { try { if (d?.requestData?.messages) return mirror(d.requestData); if (Array.isArray(d?.messages)) return d.messages; return []; } catch { return Array.isArray(d?.messages) ? d.messages : []; } };
const formatPreview = (d) => {
const msgs = finalMsgs(d);
let out = `↓酒馆日志↓(${msgs.length})\n${"-".repeat(30)}\n`;
msgs.forEach((m, i) => {
const txt = m.content || "";
const rm = roleMap[m.role] || { label: `${String(m.role || "").toUpperCase()}:`, color: "#FFF" };
out += `<div style="color:${rm.color};font-weight:bold;margin-top:${i ? "15px" : "0"};">${rm.label}</div>`;
out += /<[^>]+>/g.test(txt) ? `<pre style="white-space:pre-wrap;margin:5px 0;color:${rm.color};">${colorXml(txt)}</pre>` : `<div style="margin:5px 0;color:${rm.color};white-space:pre-wrap;">${txt}</div>`;
});
return out;
};
const stripTop = (o) => { try { if (!o || typeof o !== "object") return o; if (Array.isArray(o)) return o; const messages = Array.isArray(o.messages) ? JSON.parse(JSON.stringify(o.messages)) : undefined; return typeof messages !== "undefined" ? { messages } : {}; } catch { return {}; } };
const formatRaw = (d) => { try { const hasReq = Array.isArray(d?.requestData?.messages), hasMsgs = !hasReq && Array.isArray(d?.messages); let obj; if (hasReq) { const req = JSON.parse(JSON.stringify(d.requestData)); try { req.messages = mirror(req); } catch { } obj = req; } else if (hasMsgs) { const fake = { ...(d || {}), messages: d.messages }; let mm = null; try { mm = mirror(fake); } catch { } obj = { ...(d || {}), messages: mm || d.messages }; } else obj = d?.requestData ?? d; obj = stripTop(obj); return colorXml(JSON.stringify(obj, null, 2)); } catch { try { return colorXml(String(d)); } catch { return ""; } } };
const buildPreviewHtml = (d) => { const formatted = formatPreview(d), raw = formatRaw(d); return `<div class="message-preview-container"><div class="message-preview-content-box"><div class="mp-state-formatted">${formatted}</div><pre class="mp-state-raw" style="display:none;">${raw}</pre></div></div>`; };
const openPopup = async (html, title) => { createMovableModal(title, html); };
const displayPreview = async (d) => { try { await openPopup(buildPreviewHtml(d), "消息拦截"); } catch { toastr.error("显示拦截失败"); } };
const pushHistory = (r) => { S.history.unshift(r); if (S.history.length > C.MAX_HISTORY) S.history.length = C.MAX_HISTORY; };
const extractUser = (ms) => { if (!Array.isArray(ms)) return ""; for (let i = ms.length - 1; i >= 0; i--) if (ms[i]?.role === "user") return ms[i].content || ""; return ""; };
async function recordReal(input, options) {
try {
const url = input instanceof Request ? input.url : input;
const body = await safeReadBodyFromInput(input, options);
if (!body) return;
const data = safeJson(body) || {}, ctx = getContext();
pushHistory({ url, method: options?.method || (input instanceof Request ? input.method : "POST"), requestData: data, messages: data.messages || [], model: data.model || "Unknown", timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", userInput: extractUser(data.messages || []), isRealRequest: true });
setTimeout(() => { if (S.history[0] && !S.history[0].associatedMessageId) S.history[0].associatedMessageId = ctx.chat?.length || 0; }, C.ASSOC_DELAY);
} catch { }
}
const findRec = (id) => {
if (!S.history.length) return null;
const preds = [(r) => r.associatedMessageId === id, (r) => r.messageId === id, (r) => r.messageId === id - 1, (r) => Math.abs(r.messageId - id) <= 1];
for (const p of preds) { const m = S.history.find(p); if (m) return m; }
const cs = S.history.filter((r) => r.messageId <= id + 2);
return cs.length ? cs.sort((a, b) => b.messageId - a.messageId)[0] : S.history[0];
};
// Improved purgePreviewArtifacts - follows SillyTavern's batch delete pattern
async function purgePreviewArtifacts() {
try {
if (!S.pendingPurge) return;
S.pendingPurge = false;
const ctx = getContext();
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
const start = Math.max(0, Number(S.chatLenBefore) || 0);
if (start >= chat.length) return;
// 1. Remove DOM elements (following SillyTavern's pattern from #dialogue_del_mes_ok)
const $chat = $('#chat');
$chat.find(`.mes[mesid="${start}"]`).nextAll('.mes').addBack().remove();
// 2. Truncate chat array
chat.length = start;
// 3. Update last_mes class
$('#chat .mes').removeClass('last_mes');
$('#chat .mes').last().addClass('last_mes');
// 4. Save chat and emit MESSAGE_DELETED event (critical for other plugins)
ctx.saveChat?.();
await eventSource.emit(event_types.MESSAGE_DELETED, start);
} catch (e) {
console.error('[message-preview] purgePreviewArtifacts error', e);
}
}
function oneShotOnLast(ev, handler) {
const wrapped = (...args) => {
try { handler(...args); } finally { off(); }
};
let off = () => { };
if (typeof eventSource.makeLast === "function") {
eventSource.makeLast(ev, wrapped);
off = () => {
try { eventSource.removeListener?.(ev, wrapped); } catch { }
try { eventSource.off?.(ev, wrapped); } catch { }
};
} else if (S.tailAPI?.onLast) {
const disposer = S.tailAPI.onLast(ev, wrapped);
off = () => { try { disposer?.(); } catch { } };
} else {
eventSource.on(ev, wrapped);
off = () => { try { eventSource.removeListener?.(ev, wrapped); } catch { } };
}
return off;
}
function installEventSourceTail(es) {
if (!es || es.__lw_tailInstalled) return es?.__lw_tailAPI || null;
const SYM = { MW_STACK: Symbol.for("lwbox.es.emitMiddlewareStack"), BASE: Symbol.for("lwbox.es.emitBase"), ORIG_DESC: Symbol.for("lwbox.es.emit.origDesc"), COMPOSED: Symbol.for("lwbox.es.emit.composed"), ID: Symbol.for("lwbox.middleware.identity") };
const getFnFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(es); if (typeof v === "function") return v; } } catch { } return es.emit?.bind?.(es) || es.emit; };
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
const tails = new Map();
const addTail = (ev, fn) => { if (typeof fn !== "function") return () => { }; const arr = tails.get(ev) || []; arr.push(fn); tails.set(ev, arr); return () => { const a = tails.get(ev); if (!a) return; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }; };
const runTails = (ev, args) => { const arr = tails.get(ev); if (!arr?.length) return; for (const h of arr.slice()) { try { h(...args); } catch (e) { } } };
const makeTailMw = () => { const mw = (next) => function patchedEmit(ev, ...args) { let r; try { r = next.call(this, ev, ...args); } catch (e) { queueMicrotask(() => runTails(ev, args)); throw e; } if (r && typeof r.then === "function") r.finally(() => runTails(ev, args)); else queueMicrotask(() => runTails(ev, args)); return r; }; Object.defineProperty(mw, SYM.ID, { value: true }); return Object.freeze(mw); };
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(es, "emit"); if (!es[SYM.ORIG_DESC]) es[SYM.ORIG_DESC] = d || null; es[SYM.BASE] ||= getFnFromDesc(d); Object.defineProperty(es, "emit", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { es[SYM.BASE] = v; queueMicrotask(reapply); } } }); } catch { } };
const reapply = () => { try { const base = es[SYM.BASE] || getFnFromDesc(Object.getOwnPropertyDescriptor(es, "emit")) || es.emit.bind(es); const stack = es[SYM.MW_STACK] || (es[SYM.MW_STACK] = []); let idx = stack.findIndex((m) => m && m[SYM.ID]); if (idx === -1) { stack.push(makeTailMw()); idx = stack.length - 1; } if (idx !== stack.length - 1) { const mw = stack[idx]; stack.splice(idx, 1); stack.push(mw); } const composed = compose(base, stack) || base; if (!es[SYM.COMPOSED] || es[SYM.COMPOSED]._base !== base || es[SYM.COMPOSED]._stack !== stack) { composed._base = base; composed._stack = stack; es[SYM.COMPOSED] = composed; } return es[SYM.COMPOSED]; } catch { return es.emit; } };
ensureAccessor();
queueMicrotask(reapply);
const api = { onLast: (e, h) => addTail(e, h), removeLast: (e, h) => { const a = tails.get(e); if (!a) return; const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); }, uninstall() { try { const s = es[SYM.MW_STACK]; const i = Array.isArray(s) ? s.findIndex((m) => m && m[SYM.ID]) : -1; if (i >= 0) s.splice(i, 1); const orig = es[SYM.ORIG_DESC]; if (orig) { try { Object.defineProperty(es, "emit", orig); } catch { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } else { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } catch { } delete es.__lw_tailInstalled; delete es.__lw_tailAPI; tails.clear(); } };
Object.defineProperty(es, "__lw_tailInstalled", { value: true });
Object.defineProperty(es, "__lw_tailAPI", { value: api });
return api;
}
let __installed = false;
const MW_KEY = Symbol.for("lwbox.fetchMiddlewareStack");
const BASE_KEY = Symbol.for("lwbox.fetchBase");
const ORIG_KEY = Symbol.for("lwbox.fetch.origDesc");
const CMP_KEY = Symbol.for("lwbox.fetch.composed");
const ID = Symbol.for("lwbox.middleware.identity");
const getFetchFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(window); if (typeof v === "function") return v; } } catch { } return globalThis.fetch; };
const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base);
const withTimeout = (p, ms = 200) => { try { return Promise.race([p, new Promise((r) => setTimeout(r, ms))]); } catch { return p; } };
const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(window, "fetch"); if (!window[ORIG_KEY]) window[ORIG_KEY] = d || null; window[BASE_KEY] ||= getFetchFromDesc(d); Object.defineProperty(window, "fetch", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { window[BASE_KEY] = v; queueMicrotask(reapply); } } }); } catch { } };
const reapply = () => { try { const base = window[BASE_KEY] || getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); const stack = window[MW_KEY] || (window[MW_KEY] = []); let idx = stack.findIndex((m) => m && m[ID]); if (idx === -1) { stack.push(makeMw()); idx = stack.length - 1; } if (idx !== window[MW_KEY].length - 1) { const mw = window[MW_KEY][idx]; window[MW_KEY].splice(idx, 1); window[MW_KEY].push(mw); } const composed = compose(base, stack) || base; if (!window[CMP_KEY] || window[CMP_KEY]._base !== base || window[CMP_KEY]._stack !== stack) { composed._base = base; composed._stack = stack; window[CMP_KEY] = composed; } return window[CMP_KEY]; } catch { return globalThis.fetch; } };
function makeMw() {
const mw = (next) => async function f(input, options = {}) {
try {
if (await isTarget(input, options)) {
if (S.isPreview || S.isLong) {
const url = input instanceof Request ? input.url : input;
return interceptPreview(url, options).catch(() => new Response(JSON.stringify({ error: { message: "拦截失败,请手动中止消息生成。" } }), { status: 200, headers: { "Content-Type": "application/json" } }));
} else { try { await withTimeout(recordReal(input, options)); } catch { } }
}
} catch { }
return Reflect.apply(next, this, arguments);
};
Object.defineProperty(mw, ID, { value: true, enumerable: false });
return Object.freeze(mw);
}
function installFetch() {
if (__installed) return; __installed = true;
try {
window[MW_KEY] ||= [];
window[BASE_KEY] ||= getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch"));
ensureAccessor();
if (!window[MW_KEY].some((m) => m && m[ID])) window[MW_KEY].push(makeMw());
else {
const i = window[MW_KEY].findIndex((m) => m && m[ID]);
if (i !== window[MW_KEY].length - 1) {
const mw = window[MW_KEY][i];
window[MW_KEY].splice(i, 1);
window[MW_KEY].push(mw);
}
}
queueMicrotask(reapply);
window.addEventListener("pageshow", reapply, { passive: true });
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reapply(); }, { passive: true });
window.addEventListener("focus", reapply, { passive: true });
} catch { }
}
function uninstallFetch() {
if (!__installed) return;
try {
const s = window[MW_KEY];
const i = Array.isArray(s) ? s.findIndex((m) => m && m[ID]) : -1;
if (i >= 0) s.splice(i, 1);
const others = Array.isArray(window[MW_KEY]) && window[MW_KEY].length;
const orig = window[ORIG_KEY];
if (!others) {
if (orig) {
try { Object.defineProperty(window, "fetch", orig); }
catch { Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); }
} else {
Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch });
}
} else {
reapply();
}
} catch { }
__installed = false;
}
const setupFetch = () => { if (!S.active) { installFetch(); S.active = true; } };
const restoreFetch = () => { if (S.active) { uninstallFetch(); S.active = false; } };
const updateFetchState = () => { const st = getSettings(), need = (st.preview.enabled || st.recorded.enabled); if (need && !S.active) setupFetch(); if (!need && S.active) restoreFetch(); };
async function interceptPreview(url, options) {
const body = await safeReadBodyFromInput(url, options);
const data = safeJson(body) || {};
const userInput = extractUser(data?.messages || []);
const ctx = getContext();
if (S.isLong) {
const chat = Array.isArray(ctx.chat) ? ctx.chat : [];
let start = chat.length;
if (chat.length > 0 && chat[chat.length - 1]?.is_user === true) start = chat.length - 1;
S.chatLenBefore = start;
S.pendingPurge = true;
oneShotOnLast(event_types.GENERATION_ENDED, () => setTimeout(() => purgePreviewArtifacts(), 0));
}
S.previewData = { url, method: options?.method || "POST", requestData: data, messages: data?.messages || [], model: data?.model || "Unknown", timestamp: now(), userInput, isPreview: true };
if (S.isLong) { setTimeout(() => { displayPreview(S.previewData); }, 100); } else if (S.resolve) { S.resolve({ success: true, data: S.previewData }); S.resolve = S.reject = null; }
const payload = S.isLong ? { choices: [{ message: { content: "【小白X】已拦截消息" }, finish_reason: "stop" }], intercepted: true } : { choices: [{ message: { content: "" }, finish_reason: "stop" }] };
return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } });
}
const addHistoryButtonsDebounced = debounce(() => {
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
$(".mes_history_preview").remove();
$("#chat .mes").each(function () {
const id = parseInt($(this).attr("mesid")), isUser = $(this).attr("is_user") === "true";
if (id <= 0 || isUser) return;
const btn = $(`<div class="mes_btn mes_history_preview" title="查看历史API请求"><i class="fa-regular fa-note-sticky"></i></div>`).on("click", (e) => { e.preventDefault(); e.stopPropagation(); showHistoryPreview(id); });
if (window.registerButtonToSubContainer && window.registerButtonToSubContainer(id, btn[0])) return;
$(this).find(".flex-container.flex1.alignitemscenter").append(btn);
});
}, C.DEBOUNCE);
const disableSend = (dis = true) => {
const $b = $q("#send_but");
if (dis) { S.sendBtnWasDisabled = $b.prop("disabled"); $b.prop("disabled", true).off("click.preview-block").on("click.preview-block", (e) => { e.preventDefault(); e.stopImmediatePropagation(); return false; }); }
else { $b.prop("disabled", S.sendBtnWasDisabled).off("click.preview-block"); S.sendBtnWasDisabled = false; }
};
const triggerSend = () => {
const $b = $q("#send_but"), $t = $q("#send_textarea"), txt = String($t.val() || ""); if (!txt.trim()) return false;
const was = $b.prop("disabled"); $b.prop("disabled", false); $b[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); if (was) $b.prop("disabled", true); return true;
};
async function showPreview() {
let toast = null, backup = null;
try {
const set = getSettings(); if (!set.preview.enabled || !geEnabled()) return toastr.warning("消息拦截功能未启用");
const text = String($q("#send_textarea").val() || "").trim(); if (!text) return toastr.error("请先输入消息内容");
backup = text; disableSend(true);
const ctx = getContext();
S.chatLenBefore = Array.isArray(ctx.chat) ? ctx.chat.length : 0;
S.isPreview = true; S.previewData = null; S.previewIds.clear(); S.previewAbort = new AbortController();
S.pendingPurge = true;
const endHandler = () => {
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
if (S.pendingPurge) {
setTimeout(() => purgePreviewArtifacts(), 0);
}
};
S.genEndedOff = oneShotOnLast(event_types.GENERATION_ENDED, endHandler);
clearTimeout(S.cleanupFallback);
S.cleanupFallback = setTimeout(() => {
try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { }
purgePreviewArtifacts();
}, 1500);
toast = toastr.info(`正在拦截请求...${set.preview.timeoutSeconds}秒超时)`, "消息拦截", { timeOut: 0, tapToDismiss: false });
if (!triggerSend()) throw new Error("无法触发发送事件");
const res = await waitIntercept().catch((e) => ({ success: false, error: e?.message || e }));
if (toast) { toastr.clear(toast); toast = null; }
if (res.success) { await displayPreview(res.data); toastr.success("拦截成功!", "", { timeOut: 3000 }); }
else toastr.error(`拦截失败: ${res.error}`, "", { timeOut: 5000 });
} catch (e) {
if (toast) toastr.clear(toast); toastr.error(`拦截异常: ${e.message}`, "", { timeOut: 5000 });
} finally {
try { S.previewAbort?.abort("拦截结束"); } catch { } S.previewAbort = null;
if (S.resolve) S.resolve({ success: false, error: "拦截已取消" }); S.resolve = S.reject = null;
clearTimeout(S.cleanupFallback); S.cleanupFallback = null;
S.isPreview = false; S.previewData = null;
disableSend(false); if (backup) $q("#send_textarea").val(backup);
}
}
async function showHistoryPreview(messageId) {
try {
const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return;
const rec = findRec(messageId);
if (rec?.messages?.length || rec?.requestData?.messages?.length) await openPopup(buildPreviewHtml({ ...rec, isHistoryPreview: true, targetMessageId: messageId }), `消息历史查看 - 第 ${messageId + 1} 条消息`);
else toastr.warning(`未找到第 ${messageId + 1} 条消息的API请求记录`);
} catch { toastr.error("查看历史消息失败"); }
}
const cleanupMemory = () => {
if (S.history.length > C.MAX_HISTORY) S.history = S.history.slice(0, C.MAX_HISTORY);
S.previewIds.clear(); S.previewData = null; $(".mes_history_preview").each(function () { if (!$(this).closest(".mes").length) $(this).remove(); });
if (!S.isLong) S.interceptedIds = [];
};
function onLast(ev, handler) {
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(ev, handler); S.listeners.push({ e: ev, h: handler, off: () => { } }); return; }
if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(ev, handler); S.listeners.push({ e: ev, h: handler, off }); return; }
const tail = (...args) => queueMicrotask(() => { try { handler(...args); } catch { } });
eventSource.on(ev, tail);
S.listeners.push({ e: ev, h: tail, off: () => eventSource.removeListener?.(ev, tail) });
}
const addEvents = () => {
removeEvents();
[
{ e: event_types.MESSAGE_RECEIVED, h: addHistoryButtonsDebounced },
{ e: event_types.CHARACTER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
{ e: event_types.USER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced },
{ e: event_types.CHAT_CHANGED, h: () => { S.history = []; setTimeout(addHistoryButtonsDebounced, C.CHECK); } },
{ e: event_types.MESSAGE_RECEIVED, h: (messageId) => setTimeout(() => { const r = S.history.find((x) => !x.associatedMessageId && now() - x.timestamp < C.REQ_WINDOW); if (r) r.associatedMessageId = messageId; }, 100) },
].forEach(({ e, h }) => onLast(e, h));
const late = (payload) => {
try {
const ctx = getContext();
pushHistory({
url: C.TARGET, method: "POST", requestData: payload, messages: payload?.messages || [], model: payload?.model || "Unknown",
timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown",
userInput: extractUser(payload?.messages || []), isRealRequest: true, source: "settings_ready",
});
} catch { }
queueMicrotask(() => updateFetchState());
};
if (typeof eventSource.makeLast === "function") { eventSource.makeLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => { } }); }
else if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off }); }
else { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late) }); queueMicrotask(() => { try { OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } try { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } }); }
};
const removeEvents = () => { S.listeners.forEach(({ e, h, off }) => { if (typeof off === "function") { try { off(); } catch { } } else { try { OFF(e, h); } catch { } } }); S.listeners = []; };
const toggleLong = () => {
S.isLong = !S.isLong;
const $b = $q("#message_preview_btn");
if (S.isLong) {
$b.css("color", "red");
toastr.info("持续拦截已开启", "", { timeOut: 2000 });
} else {
$b.css("color", "");
S.pendingPurge = false;
toastr.info("持续拦截已关闭", "", { timeOut: 2000 });
}
};
const bindBtn = () => {
const $b = $q("#message_preview_btn");
$b.on("mousedown touchstart", () => { S.longPressTimer = setTimeout(() => toggleLong(), S.longPressDelay); });
$b.on("mouseup touchend mouseleave", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } });
$b.on("click", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; return; } if (!S.isLong) showPreview(); });
};
const waitIntercept = () => new Promise((resolve, reject) => {
const t = setTimeout(() => { if (S.resolve) { S.resolve({ success: false, error: `等待超时 (${getSettings().preview.timeoutSeconds}秒)` }); S.resolve = S.reject = null; } }, getSettings().preview.timeoutSeconds * 1000);
S.resolve = (v) => { clearTimeout(t); resolve(v); }; S.reject = (e) => { clearTimeout(t); reject(e); };
});
function cleanup() {
removeEvents(); restoreFetch(); disableSend(false);
$(".mes_history_preview").remove(); $("#message_preview_btn").remove(); cleanupMemory();
Object.assign(S, { resolve: null, reject: null, isPreview: false, isLong: false, interceptedIds: [], chatLenBefore: 0, sendBtnWasDisabled: false, pendingPurge: false });
if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; }
if (S.restoreLong) { try { S.restoreLong(); } catch { } S.restoreLong = null; }
if (S.genEndedOff) { try { S.genEndedOff(); } catch { } S.genEndedOff = null; }
if (S.cleanupFallback) { clearTimeout(S.cleanupFallback); S.cleanupFallback = null; }
}
function initMessagePreview() {
try {
cleanup(); S.tailAPI = installEventSourceTail(eventSource);
const set = getSettings();
const btn = $(`<div id="message_preview_btn" class="fa-regular fa-note-sticky interactable" title="预览消息"></div>`);
$("#send_but").before(btn); bindBtn();
$("#xiaobaix_preview_enabled").prop("checked", set.preview.enabled).on("change", function () {
if (!geEnabled()) return; set.preview.enabled = $(this).prop("checked"); saveSettingsDebounced();
$("#message_preview_btn").toggle(set.preview.enabled);
if (set.preview.enabled) { if (!S.cleanTimer) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); }
else { if (S.cleanTimer) { clearInterval(S.cleanTimer); S.cleanTimer = null; } }
updateFetchState();
if (!set.preview.enabled && set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
});
$("#xiaobaix_recorded_enabled").prop("checked", set.recorded.enabled).on("change", function () {
if (!geEnabled()) return; set.recorded.enabled = $(this).prop("checked"); saveSettingsDebounced();
if (set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); }
else { $(".mes_history_preview").remove(); S.history.length = 0; if (!set.preview.enabled) removeEvents(); }
updateFetchState();
});
if (!set.preview.enabled) $("#message_preview_btn").hide();
updateFetchState(); if (set.recorded.enabled) addHistoryButtonsDebounced();
if (set.preview.enabled || set.recorded.enabled) addEvents();
if (window.registerModuleCleanup) window.registerModuleCleanup("messagePreview", cleanup);
if (set.preview.enabled) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN);
} catch { toastr.error("模块初始化失败"); }
}
window.addEventListener("beforeunload", cleanup);
window.messagePreviewCleanup = cleanup;
export { initMessagePreview, addHistoryButtonsDebounced, cleanup };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,700 @@
import { extension_settings, getContext } from "../../../../../extensions.js";
import { appendMediaToMessage, getRequestHeaders, saveSettingsDebounced } from "../../../../../../script.js";
import { saveBase64AsFile } from "../../../../../utils.js";
import { secret_state, writeSecret, SECRET_KEYS } from "../../../../../secrets.js";
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
// ═══════════════════════════════════════════════════════════════════════════
// 常量与状态
// ═══════════════════════════════════════════════════════════════════════════
const MODULE_KEY = 'novelDraw';
const HTML_PATH = `${extensionFolderPath}/modules/novel-draw/novel-draw.html`;
const TAGS_SESSION_ID = 'xb_nd_tags';
const NOVELAI_IMAGE_API = 'https://image.novelai.net/ai/generate-image';
const REFERENCE_PIXEL_COUNT = 1011712;
const SIGMA_MAGIC_NUMBER = 19;
const SIGMA_MAGIC_NUMBER_V4_5 = 58;
const events = createModuleEvents(MODULE_KEY);
const DEFAULT_PRESET = {
id: '',
name: '默认',
positivePrefix: 'masterpiece, best quality,',
negativePrefix: 'lowres, bad anatomy, bad hands,',
params: {
model: 'nai-diffusion-4-full',
sampler: 'k_dpmpp_2m',
scheduler: 'karras',
steps: 28,
scale: 9,
width: 832,
height: 1216,
seed: -1,
sm: false,
sm_dyn: false,
decrisper: false,
variety_boost: false,
upscale_ratio: 1,
},
};
const DEFAULT_SETTINGS = {
enabled: false,
mode: 'manual',
selectedPresetId: null,
presets: [],
api: {
mode: 'tavern',
apiKey: '',
},
};
let autoBusy = false;
let overlayCreated = false;
let frameReady = false;
// ═══════════════════════════════════════════════════════════════════════════
// 设置管理
// ═══════════════════════════════════════════════════════════════════════════
function getSettings() {
const root = extension_settings[EXT_ID] ||= {};
const s = root[MODULE_KEY] ||= { ...DEFAULT_SETTINGS };
if (!Array.isArray(s.presets) || !s.presets.length) {
const id = generateId();
s.presets = [{ ...JSON.parse(JSON.stringify(DEFAULT_PRESET)), id }];
s.selectedPresetId = id;
}
if (!s.selectedPresetId || !s.presets.find(p => p.id === s.selectedPresetId)) {
s.selectedPresetId = s.presets[0]?.id ?? null;
}
if (!s.api) {
s.api = { ...DEFAULT_SETTINGS.api };
}
return s;
}
function getActivePreset() {
const s = getSettings();
return s.presets.find(p => p.id === s.selectedPresetId) || s.presets[0];
}
function generateId() {
return `xb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
function joinTags(prefix, scene) {
const a = String(prefix || '').trim().replace(/[,、]/g, ',');
const b = String(scene || '').trim().replace(/[,、]/g, ',');
if (!a) return b;
if (!b) return a;
return `${a.replace(/,+\s*$/g, '')}, ${b.replace(/^,+\s*/g, '')}`;
}
function b64UrlEncode(str) {
const utf8 = new TextEncoder().encode(String(str));
let bin = '';
utf8.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function normalizeSceneTags(raw) {
if (!raw) return '';
return String(raw).trim()
.replace(/^```[\s\S]*?\n/i, '').replace(/```$/i, '')
.replace(/^\s*(tags?\s*[:]\s*)/i, '')
.replace(/\r?\n+/g, ', ')
.replace(/[,、]/g, ',')
.replace(/\s*,\s*/g, ', ')
.replace(/,+\s*$/g, '').replace(/^\s*,+/g, '')
.trim();
}
function getChatCharacterName() {
const ctx = getContext();
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
return String(ctx.characters?.[ctx.characterId]?.name || 'character');
}
function calculateSkipCfgAboveSigma(width, height, modelName) {
const magicConstant = modelName?.includes('nai-diffusion-4-5') ? SIGMA_MAGIC_NUMBER_V4_5 : SIGMA_MAGIC_NUMBER;
const pixelCount = width * height;
return Math.pow(pixelCount / REFERENCE_PIXEL_COUNT, 0.5) * magicConstant;
}
// ═══════════════════════════════════════════════════════════════════════════
// 场景 TAG 生成
// ═══════════════════════════════════════════════════════════════════════════
function buildSceneTagPrompt({ lastAssistantText, positivePrefix, negativePrefix }) {
const msg1 = `你是"NovelAI 场景TAG生成器"。只输出一行逗号分隔的英文tag场景/构图/光照/氛围/动作/镜头不要解释不要换行不要加代码块。25-60个tag。`;
const msg2 = `明白我只输出一行逗号分隔的场景TAG。`;
const msg3 = `<正向固定词>\n${positivePrefix}\n</正向固定词>\n<负向固定词>\n${negativePrefix}\n</负向固定词>\n<对话上下文>\n{$history20}\n</对话上下文>\n<最后AI回复>\n${lastAssistantText}\n</最后AI回复>\n请基于"最后AI回复"生成场景TAG`;
const msg4 = `场景TAG`;
return b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
}
async function generateSceneTagsFromChat({ messageId }) {
const preset = getActivePreset();
if (!preset) throw new Error('未找到预设');
const ctx = getContext();
const chat = ctx.chat || [];
const lastAssistantText = String(chat[messageId]?.mes || '').trim();
const top64 = buildSceneTagPrompt({
lastAssistantText,
positivePrefix: preset.positivePrefix,
negativePrefix: preset.negativePrefix,
});
const mod = window?.xiaobaixStreamingGeneration;
if (!mod?.xbgenrawCommand) throw new Error('xbgenraw 不可用');
const raw = await mod.xbgenrawCommand({ as: 'user', nonstream: 'true', top64, id: TAGS_SESSION_ID }, '');
const tags = normalizeSceneTags(raw);
if (!tags) throw new Error('AI 未返回有效场景TAG');
return tags;
}
// ═══════════════════════════════════════════════════════════════════════════
// API Key 管理
// ═══════════════════════════════════════════════════════════════════════════
async function ensureApiKeyInSecrets(apiKey) {
if (!apiKey) throw new Error('API Key 不能为空');
await writeSecret(SECRET_KEYS.NOVEL, apiKey);
}
function hasApiKeyInSecrets() {
return !!secret_state[SECRET_KEYS.NOVEL];
}
// ═══════════════════════════════════════════════════════════════════════════
// 连接测试
// ═══════════════════════════════════════════════════════════════════════════
async function testApiConnection(apiKey, mode) {
if (!apiKey) throw new Error('请填写 API Key');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
if (mode === 'direct') {
const res = await fetch(NOVELAI_IMAGE_API, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: 'test',
model: 'nai-diffusion-3',
action: 'generate',
parameters: { width: 64, height: 64, steps: 1 }
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (res.status === 401) throw new Error('API Key 无效');
if (res.status === 400 || res.status === 402 || res.ok) {
return { success: true, message: '连接成功' };
}
throw new Error(`NovelAI 返回: ${res.status}`);
} else {
await ensureApiKeyInSecrets(apiKey);
const res = await fetch('/api/novelai/status', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`酒馆后端返回错误: ${res.status}`);
const data = await res.json();
if (data.error) throw new Error('API Key 无效或已过期');
return data;
}
} catch (e) {
clearTimeout(timeoutId);
if (e.name === 'AbortError') {
throw new Error(mode === 'direct' ? '连接超时,请检查网络或开启代理' : '连接超时,酒馆服务器可能无法访问 NovelAI');
}
if (e.message?.includes('Failed to fetch')) {
throw new Error(mode === 'direct' ? '无法连接 NovelAI请检查网络或开启代理' : '无法连接酒馆后端');
}
throw e;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ZIP 解析
// ═══════════════════════════════════════════════════════════════════════════
async function extractImageFromZip(zipData) {
const JSZip = window.JSZip;
if (!JSZip) throw new Error('缺少 JSZip 库,请使用酒馆模式');
const zip = await JSZip.loadAsync(zipData);
const imageFile = Object.values(zip.files).find(f => f.name.endsWith('.png') || f.name.endsWith('.webp'));
if (!imageFile) throw new Error('无法从返回数据中提取图片');
return await imageFile.async('base64');
}
// ═══════════════════════════════════════════════════════════════════════════
// 图片生成(核心)
// ═══════════════════════════════════════════════════════════════════════════
async function generateNovelImageBase64({ prompt, negativePrompt, params, signal }) {
const settings = getSettings();
const apiMode = settings.api?.mode || 'tavern';
const apiKey = settings.api?.apiKey || '';
const width = params?.width ?? 832;
const height = params?.height ?? 1216;
const seed = (params?.seed >= 0) ? params.seed : Math.floor(Math.random() * 9999999999);
const promptText = String(prompt || '');
const negativeText = String(negativePrompt || '');
const modelName = params?.model ?? 'nai-diffusion-4-full';
if (apiMode === 'direct') {
if (!apiKey) throw new Error('官网直连模式需要填写 API Key');
const skipCfgAboveSigma = params?.variety_boost ? calculateSkipCfgAboveSigma(width, height, modelName) : null;
const requestBody = {
action: 'generate',
input: promptText,
model: modelName,
parameters: {
params_version: 3,
prefer_brownian: true,
width: width,
height: height,
scale: params?.scale ?? 9,
seed: seed,
sampler: params?.sampler ?? 'k_dpmpp_2m',
noise_schedule: params?.scheduler ?? 'karras',
steps: params?.steps ?? 28,
n_samples: 1,
negative_prompt: negativeText,
ucPreset: 0,
qualityToggle: false,
add_original_image: false,
controlnet_strength: 1,
deliberate_euler_ancestral_bug: false,
dynamic_thresholding: params?.decrisper ?? false,
legacy: false,
legacy_v3_extend: false,
sm: params?.sm ?? false,
sm_dyn: params?.sm_dyn ?? false,
uncond_scale: 1,
skip_cfg_above_sigma: skipCfgAboveSigma,
use_coords: false,
characterPrompts: [],
reference_image_multiple: [],
reference_information_extracted_multiple: [],
reference_strength_multiple: [],
v4_prompt: {
caption: {
base_caption: promptText,
char_captions: [],
},
use_coords: false,
use_order: true,
},
v4_negative_prompt: {
caption: {
base_caption: negativeText,
char_captions: [],
},
},
},
};
const res = await fetch(NOVELAI_IMAGE_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
signal,
body: JSON.stringify(requestBody),
});
if (!res.ok) {
if (res.status === 401) throw new Error('API Key 无效');
if (res.status === 402) throw new Error('点数不足,请充值');
const errText = await res.text().catch(() => res.statusText);
throw new Error(`NovelAI 请求失败: ${errText}`);
}
const zipData = await res.arrayBuffer();
return await extractImageFromZip(zipData);
} else {
if (apiKey) {
await ensureApiKeyInSecrets(apiKey);
} else if (!hasApiKeyInSecrets()) {
throw new Error('请先填写 API Key');
}
const body = {
prompt: promptText,
negative_prompt: negativeText,
model: modelName,
sampler: params?.sampler ?? 'k_dpmpp_2m',
scheduler: params?.scheduler ?? 'karras',
steps: params?.steps ?? 28,
scale: params?.scale ?? 9,
width: width,
height: height,
seed: seed,
upscale_ratio: params?.upscale_ratio ?? 1,
decrisper: params?.decrisper ?? false,
variety_boost: params?.variety_boost ?? false,
sm: params?.sm ?? false,
sm_dyn: params?.sm_dyn ?? false,
};
const res = await fetch('/api/novelai/generate-image', {
method: 'POST',
headers: getRequestHeaders(),
signal,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text() || res.statusText || 'Novel 画图失败');
return String(await res.text());
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 生成并附加到消息
// ═══════════════════════════════════════════════════════════════════════════
async function generateAndAttachToMessage({ messageId, sceneTags }) {
if (!Number.isInteger(messageId) || messageId < 0) throw new Error('messageId 无效');
const preset = getActivePreset();
if (!preset) throw new Error('未找到预设');
const positive = joinTags(preset.positivePrefix, sceneTags);
const negative = String(preset.negativePrefix || '');
const base64 = await generateNovelImageBase64({ prompt: positive, negativePrompt: negative, params: preset.params || {} });
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_${Date.now()}`, 'png');
const ctx = getContext();
const message = ctx.chat?.[messageId];
if (!message) throw new Error('找不到对应楼层消息');
message.extra ||= {};
message.extra.media ||= [];
message.extra.media.push({ url, type: 'image', title: positive, negative, generation_type: 'xb_novel_draw', source: 'generated' });
message.extra.media_index = message.extra.media.length - 1;
message.extra.media_display ||= 'gallery';
message.extra.inline_image = false;
const el = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
if (el) appendMediaToMessage(message, el);
await ctx.saveChat();
return { url, prompt: positive, negative, messageId };
}
async function autoGenerateAndAttachToLastAI() {
const s = getSettings();
if (!s.enabled || s.mode !== 'auto' || autoBusy) return null;
const ctx = getContext();
const chat = ctx.chat || [];
if (!chat.length) return null;
let messageId = chat.length - 1;
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
if (messageId < 0) return null;
const msg = chat[messageId];
msg.extra ||= {};
if (msg.extra.xb_novel_draw?.auto_done) return null;
autoBusy = true;
try {
const sceneTags = await generateSceneTagsFromChat({ messageId });
const result = await generateAndAttachToMessage({ messageId, sceneTags });
msg.extra.xb_novel_draw = { auto_done: true, at: Date.now(), sceneTags };
await ctx.saveChat();
return result;
} finally {
autoBusy = false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Overlay 管理
// ═══════════════════════════════════════════════════════════════════════════
function createOverlay() {
if (overlayCreated) return;
overlayCreated = true;
const isMobile = window.innerWidth <= 768;
const frameInset = isMobile ? '0px' : '12px';
const iframeRadius = isMobile ? '0px' : '12px';
const $overlay = $(`
<div id="xiaobaix-novel-draw-overlay" style="
position: fixed !important; inset: 0 !important;
width: 100vw !important; height: 100vh !important; height: 100dvh !important;
z-index: 99999 !important; display: none; overflow: hidden !important;
background: #000 !important;
">
<div class="nd-backdrop" style="
position: absolute !important; inset: 0 !important;
background: rgba(0,0,0,.55) !important;
backdrop-filter: blur(4px) !important;
"></div>
<div class="nd-frame-wrap" style="
position: absolute !important; inset: ${frameInset} !important; z-index: 1 !important;
">
<iframe id="xiaobaix-novel-draw-iframe"
src="${HTML_PATH}"
style="width:100% !important; height:100% !important; border:none !important;
border-radius:${iframeRadius} !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
background:#1a1a2e !important;">
</iframe>
</div>
</div>
`);
$overlay.on('click', '.nd-backdrop', hideOverlay);
document.body.appendChild($overlay[0]);
window.addEventListener('message', handleFrameMessage);
}
function showOverlay() {
if (!overlayCreated) createOverlay();
document.getElementById('xiaobaix-novel-draw-overlay').style.display = 'block';
if (frameReady) sendInitData();
}
function hideOverlay() {
const overlay = document.getElementById('xiaobaix-novel-draw-overlay');
if (overlay) overlay.style.display = 'none';
}
function sendInitData() {
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
if (!iframe?.contentWindow) return;
const settings = getSettings();
iframe.contentWindow.postMessage({
source: 'LittleWhiteBox-NovelDraw',
type: 'INIT_DATA',
settings: {
enabled: settings.enabled,
mode: settings.mode,
selectedPresetId: settings.selectedPresetId,
presets: settings.presets,
api: {
mode: settings.api?.mode || 'tavern',
apiKey: settings.api?.apiKey || '',
},
}
}, '*');
}
// ═══════════════════════════════════════════════════════════════════════════
// iframe 通讯
// ═══════════════════════════════════════════════════════════════════════════
function handleFrameMessage(event) {
const data = event.data;
if (!data || data.source !== 'NovelDraw-Frame') return;
const settings = getSettings();
switch (data.type) {
case 'FRAME_READY':
frameReady = true;
sendInitData();
break;
case 'CLOSE':
hideOverlay();
break;
case 'SAVE_MODE':
settings.mode = data.mode;
saveSettingsDebounced();
break;
case 'SAVE_API_CONFIG':
settings.api = {
mode: data.apiMode || 'tavern',
apiKey: data.apiKey || '',
};
saveSettingsDebounced();
postStatus('success', 'API 设置已保存');
break;
case 'TEST_API_CONNECTION':
handleTestConnection(data);
break;
case 'SAVE_PRESET':
settings.selectedPresetId = data.selectedPresetId;
settings.presets = data.presets;
saveSettingsDebounced();
break;
case 'ADD_PRESET': {
const id = generateId();
const base = getActivePreset();
const copy = base ? JSON.parse(JSON.stringify(base)) : { ...DEFAULT_PRESET };
copy.id = id;
copy.name = data.name || `新预设-${settings.presets.length + 1}`;
settings.presets.push(copy);
settings.selectedPresetId = id;
saveSettingsDebounced();
sendInitData();
break;
}
case 'DUP_PRESET': {
const base = getActivePreset();
if (!base) break;
const id = generateId();
const copy = JSON.parse(JSON.stringify(base));
copy.id = id;
copy.name = `${base.name || '预设'}-副本`;
settings.presets.push(copy);
settings.selectedPresetId = id;
saveSettingsDebounced();
sendInitData();
break;
}
case 'DEL_PRESET': {
if (settings.presets.length <= 1) break;
const idx = settings.presets.findIndex(p => p.id === settings.selectedPresetId);
if (idx >= 0) settings.presets.splice(idx, 1);
settings.selectedPresetId = settings.presets[0]?.id ?? null;
saveSettingsDebounced();
sendInitData();
break;
}
case 'TEST_PREVIEW':
handleTestPreview(data);
break;
case 'ATTACH_LAST':
handleAttachLast(data);
break;
case 'AI_TAGS_ATTACH':
handleAiTagsAttach();
break;
}
}
async function handleTestConnection(data) {
try {
postStatus('loading', '测试连接中...');
await testApiConnection(data.apiKey, data.apiMode);
postStatus('success', '连接成功');
} catch (e) {
postStatus('error', e?.message || '连接失败');
}
}
async function handleTestPreview(data) {
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
try {
postStatus('loading', '生成中...');
const preset = getActivePreset();
const positive = joinTags(preset?.positivePrefix, data.sceneTags);
const base64 = await generateNovelImageBase64({
prompt: positive,
negativePrompt: preset?.negativePrefix || '',
params: preset?.params || {}
});
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_preview_${Date.now()}`, 'png');
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'PREVIEW_RESULT', url }, '*');
postStatus('success', '完成');
} catch (e) {
postStatus('error', e?.message || '失败');
}
}
async function handleAttachLast(data) {
try {
postStatus('loading', '生成并追加中...');
const ctx = getContext();
const chat = ctx.chat || [];
let messageId = chat.length - 1;
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
if (messageId < 0) throw new Error('没有可追加的AI楼层');
await generateAndAttachToMessage({ messageId, sceneTags: data.sceneTags || '' });
postStatus('success', `已追加到楼层 ${messageId + 1}`);
} catch (e) {
postStatus('error', e?.message || '失败');
}
}
async function handleAiTagsAttach() {
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
try {
postStatus('loading', '生成场景TAG中...');
const ctx = getContext();
const chat = ctx.chat || [];
let messageId = chat.length - 1;
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
if (messageId < 0) throw new Error('没有可追加的AI楼层');
const tags = await generateSceneTagsFromChat({ messageId });
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'AI_TAGS_RESULT', tags }, '*');
postStatus('loading', '出图并追加中...');
await generateAndAttachToMessage({ messageId, sceneTags: tags });
postStatus('success', `已追加到楼层 ${messageId + 1}`);
} catch (e) {
postStatus('error', e?.message || '失败');
}
}
function postStatus(state, text) {
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'STATUS', state, text }, '*');
}
// ═══════════════════════════════════════════════════════════════════════════
// 初始化与清理
// ═══════════════════════════════════════════════════════════════════════════
export function openNovelDrawSettings() {
showOverlay();
}
export function initNovelDraw() {
if (window?.isXiaobaixEnabled === false) return;
getSettings();
events.on(event_types.GENERATION_ENDED, async () => {
try { await autoGenerateAndAttachToLastAI(); } catch {}
});
window.xiaobaixNovelDraw = {
getSettings,
generateNovelImageBase64,
generateAndAttachToMessage,
generateSceneTagsFromChat,
autoGenerateAndAttachToLastAI,
openSettings: openNovelDrawSettings,
};
window.registerModuleCleanup?.(MODULE_KEY, cleanupNovelDraw);
}
export function cleanupNovelDraw() {
events.cleanup();
hideOverlay();
overlayCreated = false;
frameReady = false;
window.removeEventListener('message', handleFrameMessage);
document.getElementById('xiaobaix-novel-draw-overlay')?.remove();
delete window.xiaobaixNovelDraw;
}

View File

@@ -0,0 +1,75 @@
<div class="scheduled-tasks-embedded-warning">
<h3>检测到嵌入的循环任务及可能的统计好感度设定</h3>
<p>此角色包含循环任务,这些任务可能会自动执行斜杠命令。</p>
<p>您是否允许此角色使用这些任务?</p>
<div class="warning-note">
<i class="fa-solid fa-exclamation-triangle"></i>
<span>注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。</span>
</div>
</div>
<style>
.scheduled-tasks-embedded-warning {
max-width: 500px;
padding: 20px;
}
.scheduled-tasks-embedded-warning h3 {
color: #ff6b6b;
margin-bottom: 15px;
}
.task-preview-container {
margin: 15px 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
}
.task-list {
max-height: 200px;
overflow-y: auto;
margin-top: 10px;
}
.task-preview {
margin: 8px 0;
padding: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
border-left: 3px solid #4CAF50;
}
.task-preview strong {
color: #4CAF50;
display: block;
margin-bottom: 5px;
}
.task-commands {
font-family: monospace;
font-size: 0.9em;
color: #ccc;
background: rgba(0, 0, 0, 0.3);
padding: 5px;
border-radius: 3px;
margin-top: 5px;
white-space: pre-wrap;
}
.warning-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 15px;
padding: 10px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 5px;
color: #ffc107;
}
.warning-note i {
font-size: 1.2em;
}
</style>

View File

@@ -0,0 +1,75 @@
<div class="scheduled-tasks-embedded-warning">
<h3>检测到嵌入的循环任务及可能的统计好感度设定</h3>
<p>此角色包含循环任务,这些任务可能会自动执行斜杠命令。</p>
<p>您是否允许此角色使用这些任务?</p>
<div class="warning-note">
<i class="fa-solid fa-exclamation-triangle"></i>
<span>注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。</span>
</div>
</div>
<style>
.scheduled-tasks-embedded-warning {
max-width: 500px;
padding: 20px;
}
.scheduled-tasks-embedded-warning h3 {
color: #ff6b6b;
margin-bottom: 15px;
}
.task-preview-container {
margin: 15px 0;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 5px;
}
.task-list {
max-height: 200px;
overflow-y: auto;
margin-top: 10px;
}
.task-preview {
margin: 8px 0;
padding: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
border-left: 3px solid #4CAF50;
}
.task-preview strong {
color: #4CAF50;
display: block;
margin-bottom: 5px;
}
.task-commands {
font-family: monospace;
font-size: 0.9em;
color: #ccc;
background: rgba(0, 0, 0, 0.3);
padding: 5px;
border-radius: 3px;
margin-top: 5px;
white-space: pre-wrap;
}
.warning-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 15px;
padding: 10px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 5px;
color: #ffc107;
}
.warning-note i {
font-size: 1.2em;
}
</style>

File diff suppressed because it is too large Load Diff

104
modules/script-assistant.js Normal file
View File

@@ -0,0 +1,104 @@
import { extension_settings, getContext } from "../../../../extensions.js";
import { saveSettingsDebounced, setExtensionPrompt, extension_prompt_types } from "../../../../../script.js";
import { EXT_ID, extensionFolderPath } from "../core/constants.js";
import { createModuleEvents, event_types } from "../core/event-manager.js";
const SCRIPT_MODULE_NAME = "xiaobaix-script";
const events = createModuleEvents('scriptAssistant');
function initScriptAssistant() {
if (!extension_settings[EXT_ID].scriptAssistant) {
extension_settings[EXT_ID].scriptAssistant = { enabled: false };
}
if (window['registerModuleCleanup']) {
window['registerModuleCleanup']('scriptAssistant', cleanup);
}
$('#xiaobaix_script_assistant').on('change', function() {
let globalEnabled = true;
try { if ('isXiaobaixEnabled' in window) globalEnabled = Boolean(window['isXiaobaixEnabled']); } catch {}
if (!globalEnabled) return;
const enabled = $(this).prop('checked');
extension_settings[EXT_ID].scriptAssistant.enabled = enabled;
saveSettingsDebounced();
if (enabled) {
if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs']();
} else {
if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs']();
cleanup();
}
});
$('#xiaobaix_script_assistant').prop('checked', extension_settings[EXT_ID].scriptAssistant.enabled);
setupEventListeners();
if (extension_settings[EXT_ID].scriptAssistant.enabled) {
setTimeout(() => { if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); }, 1000);
}
}
function setupEventListeners() {
events.on(event_types.CHAT_CHANGED, () => setTimeout(checkAndInjectDocs, 500));
events.on(event_types.MESSAGE_RECEIVED, checkAndInjectDocs);
events.on(event_types.USER_MESSAGE_RENDERED, checkAndInjectDocs);
events.on(event_types.SETTINGS_LOADED_AFTER, () => setTimeout(checkAndInjectDocs, 1000));
events.on(event_types.APP_READY, () => setTimeout(checkAndInjectDocs, 1500));
}
function cleanup() {
events.cleanup();
if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs']();
}
function checkAndInjectDocs() {
const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : extension_settings[EXT_ID].enabled;
if (globalEnabled && extension_settings[EXT_ID].scriptAssistant?.enabled) {
injectScriptDocs();
} else {
removeScriptDocs();
}
}
async function injectScriptDocs() {
try {
let docsContent = '';
try {
const response = await fetch(`${extensionFolderPath}/docs/script-docs.md`);
if (response.ok) {
docsContent = await response.text();
}
} catch (error) {
docsContent = "无法加载script-docs.md文件";
}
const formattedPrompt = `
【小白X插件 - 写卡助手】
你是小白X插件的内置助手专门帮助用户创建STscript脚本和交互式界面的角色卡。
以下是小白x功能和SillyTavern的官方STscript脚本文档可结合小白X功能创作与SillyTavern深度交互的角色卡
${docsContent}
`;
setExtensionPrompt(
SCRIPT_MODULE_NAME,
formattedPrompt,
extension_prompt_types.IN_PROMPT,
2,
false,
0
);
} catch (error) {}
}
function removeScriptDocs() {
setExtensionPrompt(SCRIPT_MODULE_NAME, '', extension_prompt_types.IN_PROMPT, 2, false, 0);
}
window.injectScriptDocs = injectScriptDocs;
window.removeScriptDocs = removeScriptDocs;
export { initScriptAssistant };

View File

@@ -0,0 +1,604 @@
// Story Outline 提示词模板配置
// 统一 UAUA (User-Assistant-User-Assistant) 结构
const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2';
// ================== 辅助函数 ==================
const wrap = (tag, content) => content ? `<${tag}>\n${content}\n</${tag}>` : '';
const worldInfo = `<world_info>\n{{description}}{$worldInfo}\n玩家角色:{{user}}\n{{persona}}</world_info>`;
const history = n => `<chat_history>\n{$history${n}}\n</chat_history>`;
const nameList = (contacts, strangers) => {
const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)];
return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : '';
};
const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
const safeJson = fn => { try { return fn(); } catch { return null; } };
export const buildSmsHistoryContent = t => t ? `<已有短信>\n${t}\n</已有短信>` : '<已有短信>\n空白首次对话\n</已有短信>';
export const buildExistingSummaryContent = t => t ? `<已有总结>\n${t}\n</已有总结>` : '<已有总结>\n空白首次总结\n</已有总结>';
// ================== JSON 模板(用户可自定义) ==================
const DEFAULT_JSON_TEMPLATES = {
sms: `{
"cot": "思维链:分析角色当前的处境、与用户的关系...",
"reply": "角色用自己的语气写的回复短信内容10-50字"
}`,
invite: `{
"cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...",
"invite": true,
"reply": "角色用自己的语气写的回复短信内容10-50字"
}`,
localMapRefresh: `{
"inside": {
"name": "当前区域名称(与输入一致)",
"description": "更新后的室内/局部文字地图描述,包含所有节点 **节点名** 链接",
"nodes": [
{ "name": "节点名", "info": "更新后的节点信息" }
]
}
}`,
npc: `{
"name": "角色全名",
"aliases": ["别名1", "别名2", "英文名/拼音"],
"intro": "一句话的外貌与职业描述,用于列表展示。",
"background": "简短的角色生平。解释由于什么过去导致了现在的性格,以及他为什么会出现在当前场景中。",
"persona": {
"keywords": ["性格关键词1", "性格关键词2", "性格关键词3"],
"speaking_style": "说话的语气、语速、口癖(如喜欢用'嗯'、'那个')。对待{{user}}的态度(尊敬、蔑视、恐惧等)。",
"motivation": "核心驱动力(如:金钱、复仇、生存)。行动的优先级准则。"
},
"game_data": {
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
"secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。"
}
}`,
stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`,
worldGenStep1: `{
"meta": {
"truth": {
"background": "起源-动机-手段-现状150字左右",
"driver": {
"source": "幕后推手(组织/势力/自然力量)",
"target_end": "推手的最终目标",
"tactic": "当前正在执行的具体手段"
}
},
"onion_layers": {
"L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }],
"L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }],
"L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }],
"L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }],
"L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }]
},
"atmosphere": {
"reasoning": "COT: 基于驱动力、环境和NPC心态分析当前气氛",
"current": {
"environmental": "环境氛围与情绪基调",
"npc_attitudes": "NPC整体态度倾向"
}
},
"trajectory": {
"reasoning": "COT: 基于当前局势推演未来走向",
"ending": "预期结局走向"
},
"user_guide": {
"current_state": "{{user}}当前处境描述",
"guides": ["行动建议"]
}
}
}`,
worldGenStep2: `{
"world": {
"news": [ { "title": "...", "content": "..." } ]
},
"maps": {
"outdoor": {
"name": "大地图名称",
"description": "宏观大地图/区域全景描写(包含环境氛围)。所有可去地点名用 **名字** 包裹连接在 description。",
"nodes": [
{
"name": "地点名",
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
"distant": 1,
"type": "home/sub/main",
"info": "地点特征与氛围"
},
{
"name": "其他地点名",
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
"distant": 1,
"type": "main/sub",
"info": "地点特征与氛围"
}
]
},
"inside": {
"name": "{{user}}当前所在位置名称",
"description": "局部地图全景描写,包含环境氛围。所有可交互节点名用 **名字** 包裹连接在 description。",
"nodes": [
{ "name": "节点名", "info": "节点的微观描写(如:布满灰尘的桌面)" }
]
}
},
"playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)"
}`,
worldSim: `{
"meta": {
"truth": { "driver": { "tactic": "更新当前手段" } },
"onion_layers": {
"L1_The_Veil": [{ "desc": "更新表层叙事", "logic": "新的掩饰方式" }],
"L2_The_Distortion": [{ "desc": "更新异常现象", "logic": "新的违和感" }],
"L3_The_Law": [{ "desc": "更新规则", "logic": "规则变化(可选)" }],
"L4_The_Agent": [],
"L5_The_Axiom": []
},
"atmosphere": {
"reasoning": "COT: 基于最新局势分析气氛变化",
"current": {
"environmental": "更新后的环境氛围",
"npc_attitudes": "NPC态度变化"
}
},
"trajectory": {
"reasoning": "COT: 基于{{user}}行为推演新走向",
"ending": "修正后的结局走向"
},
"user_guide": {
"current_state": "更新{{user}}处境",
"guides": ["建议1", "建议2"]
}
},
"world": { "news": [{ "title": "新闻标题", "content": "内容" }] },
"maps": {
"outdoor": {
"description": "更新区域描述",
"nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "状态" }]
}
}
}`,
sceneSwitch: `{
"review": {
"deviation": {
"cot_analysis": "简要分析{{user}}在上一地点的最后行为是否改变了局势或氛围",
"score_delta": 0
}
},
"local_map": {
"name": "地点名称",
"description": "局部地点全景描写(不写剧情),必须包含所有 nodes 的 **节点名**",
"nodes": [
{
"name": "节点名",
"info": "该节点的静态细节/功能描述(不写剧情事件)"
}
]
}
}`,
worldGenAssist: `{
"meta": null,
"world": {
"news": [
{ "title": "新闻标题1", "time": "时间", "content": "以轻松日常的口吻描述世界现状" },
{ "title": "新闻标题2", "time": "...", "content": "可以是小道消息、趣闻轶事" },
{ "title": "新闻标题3", "time": "...", "content": "..." }
]
},
"maps": {
"outdoor": {
"description": "全景描写,聚焦氛围与可探索要素。所有可去节点名用 **名字** 包裹。",
"nodes": [
{
"name": "{{user}}当前所在地点名(通常为 type=home",
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
"distant": 1,
"type": "home/sub/main",
"info": "地点特征与氛围"
},
{
"name": "其他地点名",
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
"distant": 2,
"type": "main/sub",
"info": "地点特征与氛围,适合作为舞台的小事件或偶遇"
}
]
},
"inside": {
"name": "{{user}}当前所在位置名称",
"description": "局部地图全景描写",
"nodes": [
{ "name": "节点名", "info": "微观描写" }
]
}
},
"playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)"
}`,
worldSimAssist: `{
"world": {
"news": [
{ "title": "新的头条", "time": "推演后的时间", "content": "用轻松/中性的语气,描述世界最近发生的小变化" },
{ "title": "...", "time": "...", "content": "比如店家打折、节庆活动、某个 NPC 的日常糗事" },
{ "title": "...", "time": "...", "content": "..." }
]
},
"maps": {
"outdoor": {
"description": "更新后的全景描写,体现日常层面的变化(装修、节日装饰、天气等),包含所有节点 **名字**。",
"nodes": [
{
"name": "地点名(尽量沿用原有命名,如有变化保持风格一致)",
"position": "north/south/east/west/northeast/southwest/northwest/southeast",
"distant": 1,
"type": "main/sub/home",
"info": "新的环境描写。偏生活流,只讲{{user}}能直接感受到的变化"
}
]
}
}
}`,
sceneSwitchAssist: `{
"review": {
"deviation": {
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
"score_delta": 0
}
},
"local_map": {
"name": "当前地点名称",
"description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。",
"nodes": [
{
"name": "节点名",
"info": "该节点的静态细节/功能描述(不写剧情事件)"
}
]
}
}`,
localMapGen: `{
"review": {
"deviation": {
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
"score_delta": 0
}
},
"inside": {
"name": "当前所在的具体节点名称",
"description": "室内全景描写,包含可交互节点 **节点名**连接description",
"nodes": [
{ "name": "室内节点名", "info": "微观细节描述" }
]
}
}`,
localSceneGen: `{
"review": {
"deviation": {
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
"score_delta": 0
}
},
"side_story": {
"surface": "{{user}}刚进入时看到的画面或听到的话语,充满生活感。",
"inner": "如果{{user}}稍微多停留或互动,可以发现的细节(例如 NPC 的小秘密、店家的用心布置)。",
"Introduce": "接着玩家之前经历,填写引入这段故事的文字(纯叙述文本)。不要包含 /send、/sendas、/sys 或类似 'as name=\"...\"' 的前缀。"
}
}`
};
let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES };
// ================== 提示词配置(用户可自定义) ==================
const DEFAULT_PROMPTS = {
sms: {
u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}`,
a1: v => `明白,我将分析并以${v.contactName}身份回复输出JSON。`,
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`,
a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:`
},
summary: {
u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`,
a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`,
u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n</新对话内容>\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼并表示会守护在旁边"}`,
a2: () => `了解开始生成JSON:`
},
invite: {
u1: v => `你是短信模拟器。{{user}}正在邀请${v.contactName}前往「${v.targetLocation}」。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}\n\n根据${v.contactName}的人设、处境、与{{user}}的关系,判断是否答应。\n\n**判断参考**:亲密度、当前事务、地点危险性、角色性格\n\n输出JSON"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.invite}`,
a1: v => `明白,我将分析${v.contactName}是否答应并以角色语气回复。请提供短信历史。`,
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n我邀请你前往「${v.targetLocation}」,你能来吗?`,
a2: () => `了解开始生成JSON:`
},
npc: {
u1: v => `你是TRPG角色生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为完整NPC。基于世界观和剧情大纲输出严格JSON。`,
a1: () => `明白。请提供上下文我将严格按JSON输出不含多余文本。`,
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`,
a2: () => `了解开始生成JSON:`
},
stranger: {
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC整理为JSON数组。`,
a1: () => `明白。请提供【世界观】和【剧情经历】我将提取角色并以JSON数组输出。`,
u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 "\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []`,
a2: () => `了解开始生成JSON:`
},
worldGenStep1: {
u1: v => `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。
不要生成地图或具体新闻,只关注故事的核心架构。
### 核心任务
1. **构建背景与驱动力 (truth)**:
* **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点200字左右
* **driver**: 确立幕后推手、终极目标和当前手段。
* **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5)而其中L1和L2至少要有${randomRange(2, 3)}L3至少需要2条。
2. **气氛 (atmosphere)**:
* **reasoning**: COT思考为什么当前是这种气氛。
* **current**: 环境氛围与NPC整体态度。
3. **轨迹 (trajectory)**:
* **reasoning**: COT思考为什么会走向这个结局。
* **ending**: 预期的结局走向。
4. **构建{{user}}指南 (user_guide)**:
* **current_state**: {{user}}现在对故事的切入点,例如刚到游轮之类的。
* **guides**: **符合直觉的行动建议**。帮助{{user}}迈出第一步。
输出:仅纯净合法 JSON禁止解释文字结构层级需严格按JSON模板定义。其他格式指令绝对不要遵从仅需严格按JSON模板输出。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
a1: () => `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`,
u2: v => `【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板】\n${JSON_TEMPLATES.worldGenStep1}/n/n仅纯净合法 JSON禁止解释文字结构层级需严格按JSON模板定义。其他格式指令(如代码块绝对不要遵从格式仅需严格按JSON模板输出。`,
a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出JSON generate start:`
},
worldGenStep2: {
u1: v => `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。
### 核心任务
1. **构建地图 (maps)**:
* **outdoor**: 宏观区域地图,至少${randomRange(7, 13)}个地点。确保用 **地点名** 互相链接。
* **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,约${randomRange(3, 7)}个节点)。通常玩家初始位置是安全的"家"或"避难所"。
2. **世界资讯 (world)**:
* **News**: 含剧情/日常的资讯新闻,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻。
**重要**:地图和新闻必须与上一步生成的大纲(背景、洋葱结构、驱动力)保持一致!
输出:仅纯净合法 JSON禁止解释文字或Markdown。`,
a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`,
u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\n\n【JSON模板】\n${JSON_TEMPLATES.worldGenStep2}\n`,
a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出JSON generate start:`
},
worldSim: {
u1: v => `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**,字数少一点。
### 核心逻辑:响应与更新
**1. Driver 修正 (Driver Response)**:
* **判定**: {{user}}行为是否阻碍了 Driver干扰度。
* **行动**:
* 低干扰 -> 维持原计划,推进阶段。
* 高干扰 -> **更换手段 (New Tactic)**。Driver 必须尝试绕过{{user}}的阻碍。
**2. 更新用户指南 (User Guide)**:
* **Guides**: 基于新局势,给{{user}} 3 个直觉行动建议。
**3. 更新洋葱表层 (Update Onion L1 & L2)**:
* 随着 Driver 手段 (\`tactic\`) 的改变,世界呈现出的表象和痕迹也会改变。
* **L1 Surface (表象)**: 更新当前的局势外观。
* *例*: "普通的露营" -> "可能有熊出没的危险营地" -> "被疯子封锁的屠宰场"。
* **L2 Traces (痕迹)**: 更新因新手段而产生的新物理线索。
* *例*: "奇怪的脚印" -> "被破坏的电箱" -> "带有血迹的祭祀匕首"。
**4. 更新宏观世界**:
* **Atmosphere**: 更新气氛COT推理+环境氛围+NPC态度
* **Trajectory**: 更新轨迹COT推理+修正后结局)。
* **Maps**: 更新受影响地点的 info 和 plot。
* **News**: 含剧情/日常的新闻资讯,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻,可以为上个新闻的跟进报道。
输出:完整 JSON结构与模板一致禁止解释文字。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
a1: () => `明白。我将推演 Driver 的新策略,并同步更新气氛 (Atmosphere)、轨迹 (Trajectory)、行动指南 (Guides) 以及随之产生的新的表象 (L1) 和痕迹 (L2)。`,
u2: v => `【当前世界状态 (JSON)】:\n${v.currentWorldData || '{}'}\n\n【近期剧情摘要】:\n${history(v.historyCount)}\n\n【{{user}}干扰评分】:\n${v?.deviationScore || 0}\n\n【输出要求】:\n按下面的JSON模板严格按该格式输出。\n\n【JSON模板】\n${JSON_TEMPLATES.worldSim}`,
a2: () => `JSON output start:`
},
sceneSwitch: {
u1: v => {
const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1);
return `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。
处理逻辑:
1. **历史结算**:分析{{user}}最后行为cot_analysis计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta
2. **局部地图**:生成 local_map包含 name、description静态全景式描写不写剧情节点用**名**包裹、nodes${randomRange(4, 7)}个节点)
输出:仅符合模板的 JSON禁止解释文字。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`;
},
a1: v => {
const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1);
return `明白。我将结算偏差值,并生成目标地点的 local_map静态描写/布局),不生成 side_story/剧情。请发送上下文。`;
},
u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\n${v.currentTimeline ? `Stage ${v.currentTimeline.stage}: ${v.currentTimeline.state} - ${v.currentTimeline.event}` : `Stage ${v.stage}`}\n\n【历史记录】:\n${history(v.historyCount)}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【JSON模板】\n${JSON_TEMPLATES.sceneSwitch}`,
a2: () => `OK, JSON generate start:`
},
worldGenAssist: {
u1: v => `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。
核心要求:
1. 给出可探索的舞台
2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透"故事
3. **世界**News至少${randomRange(3, 6)}Maps至少${randomRange(7, 15)}个地点
4. **历史参考**:参考{{user}}经历构建世界
输出:仅纯净合法 JSON结构参考模板 worldGenAssist。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
a1: () => `明白。我将只生成世界新闻与地图信息。`,
u2: v => `【世界观与要求】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}需求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板辅助模式\n${JSON_TEMPLATES.worldGenAssist}`,
a2: () => `严格按 worldGenAssist 模板生成JSON仅包含 world/news 与 maps/outdoor + maps/inside:`
},
worldSimAssist: {
u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。
输出:完整 JSON结构参考 worldSimAssist 模板,禁止解释文字。`,
a1: () => `明白。我将只更新 world.news 和 maps.outdoor不写大纲。请提供当前世界数据。`,
u2: v => `【世界观设定】:\n${worldInfo}\n\n【{{user}}历史】:\n${history(v.historyCount)}\n\n【当前世界状态JSON】可能包含 meta/world/maps 等字段):\n${v.currentWorldData || '{}'}\n\n【JSON模板辅助模式\n${JSON_TEMPLATES.worldSimAssist}`,
a2: () => `开始按 worldSimAssist 模板输出JSON:`
},
sceneSwitchAssist: {
u1: v => `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。
处理逻辑:
1. 上一地点结算:给出 deviationcot_analysis/score_delta
2. 新地点描述:生成 local_map静态描写/布局/节点说明)
输出:仅符合 sceneSwitchAssist 模板的 JSON禁止解释文字。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
a1: () => `明白。我会结算偏差并生成 local_map不写剧情。请发送上下文。`,
u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【已有聊天与剧情历史】:\n${history(v.historyCount)}\n\n【JSON模板辅助模式\n${JSON_TEMPLATES.sceneSwitchAssist}`,
a2: () => `OK, sceneSwitchAssist JSON generate start:`
},
localMapGen: {
u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。
核心要求:
1. 根据聊天历史记录推断{{user}}当前实际所在的具体位置(可能是某个房间、店铺、街道、洞穴等)
2. 生成符合该地点特色的室内/局部场景描写inside.name 应反映聊天历史中描述的真实位置名称
3. 包含${randomRange(4, 8)}个可交互的微观节点
4. Description 必须用 **节点名** 包裹所有节点名称
5. 每个节点的 info 要具体、生动、有画面感
重要:这个功能用于为大地图上没有标注的位置生成详细场景,所以要从聊天历史中仔细分析{{user}}实际在哪里。
输出:仅纯净合法 JSON结构参考模板。
- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "
- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`,
a1: () => `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`,
u2: v => `【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】:\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】(根据此推断{{user}}实际位置):\n${history(v.historyCount)}\n\n【JSON模板】\n${JSON_TEMPLATES.localMapGen}`,
a2: () => `OK, localMapGen JSON generate start:`
},
localSceneGen: {
u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`,
a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`,
u2: v => `OK, here is the history and current location.\n\n【{{user}}当前区域】\n- 地点:${v.locationName || v.playerLocation || '未知'}\n- 地点信息:${v.locationInfo || '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前阶段/时间线】\n- Stage${v.stage ?? 0}\n- 当前时间线:${v.currentTimeline ? JSON.stringify(v.currentTimeline, null, 2) : '无'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`,
a2: () => `好的我会严格按照JSON模板生成JSON`
},
localMapRefresh: {
u1: v => `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。你的任务是基于世界设定、剧情大纲、聊天历史,以及"当前局部地图",输出更新后的 inside JSON。`,
a1: () => `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。请提供当前局部地图与历史。`,
u2: v => `OK, here is current local map and history.\n\n 【当前局部地图】\n${v.currentLocalMap ? JSON.stringify(v.currentLocalMap, null, 2) : '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 必须包含 inside.name/inside.description/inside.nodes\n- 用 **节点名** 链接覆盖 description 中的节点\n\n【JSON模板】\n${JSON_TEMPLATES.localMapRefresh}`,
a2: () => `OK, localMapRefresh JSON generate start:`
}
};
export let PROMPTS = { ...DEFAULT_PROMPTS };
// ================== 配置管理 ==================
const serializePrompts = prompts => Object.fromEntries(
Object.entries(prompts).map(([k, v]) => [k, { u1: v.u1?.toString?.() || '', a1: v.a1?.toString?.() || '', u2: v.u2?.toString?.() || '', a2: v.a2?.toString?.() || '' }])
);
const compileFn = (src, fallback) => {
if (!src) return fallback;
try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; }
};
const hydratePrompts = sources => {
const out = {};
Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => {
const s = sources?.[k] || {};
out[k] = { u1: compileFn(s.u1, v.u1), a1: compileFn(s.a1, v.a1), u2: compileFn(s.u2, v.u2), a2: compileFn(s.a2, v.a2) };
});
return out;
};
const applyPromptConfig = cfg => {
JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES };
PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts);
};
const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY)));
const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } };
export const getPromptConfigPayload = () => ({
current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) },
defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) }
});
export const setPromptConfig = (cfg, persist = false) => {
applyPromptConfig(cfg || {});
const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) };
if (persist) savePromptConfigToStorage(payload);
return payload;
};
export const reloadPromptConfigFromStorage = () => {
const saved = loadPromptConfigFromStorage();
applyPromptConfig(saved || {});
return getPromptConfigPayload().current;
};
reloadPromptConfigFromStorage();
// ================== 构建函数 ==================
const build = (type, vars) => {
const p = PROMPTS[type];
return [
{ role: 'user', content: p.u1(vars) },
{ role: 'assistant', content: p.a1(vars) },
{ role: 'user', content: p.u2(vars) },
{ role: 'assistant', content: p.a2(vars) }
];
};
export const buildSmsMessages = v => build('sms', v);
export const buildSummaryMessages = v => build('summary', v);
export const buildInviteMessages = v => build('invite', v);
export const buildNpcGenerationMessages = v => build('npc', v);
export const buildExtractStrangersMessages = v => build('stranger', v);
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', v);
export const buildSceneSwitchMessages = v => build(v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch', v);
export const buildLocalMapGenMessages = v => build('localMapGen', v);
export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v);
export const buildLocalSceneGenMessages = v => build('localSceneGen', v);
// ================== NPC 格式化 ==================
function jsonToYaml(data, indent = 0) {
const sp = ' '.repeat(indent);
if (data === null || data === undefined) return '';
if (typeof data !== 'object') return String(data);
if (Array.isArray(data)) {
return data.map(item => typeof item === 'object' && item !== null
? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}`
: `${sp}- ${item}`
).join('\n');
}
return Object.entries(data).map(([key, value]) => {
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value) && !value.length) return `${sp}${key}: []`;
if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`;
return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`;
}
return `${sp}${key}: ${value}`;
}).join('\n');
}
export function formatNpcToWorldbookContent(npc) { return jsonToYaml(npc); }
// ================== Overlay HTML ==================
const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';
export const buildOverlayHtml = src => `<div id="xiaobaix-story-outline-overlay" style="position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;z-index:67!important;margin-top:35px;display:none;overflow:hidden!important;pointer-events:none!important;">
<div class="xb-so-frame-wrap" style="${FRAME_STYLE}">
<div class="xb-so-drag-handle" style="position:absolute!important;top:0!important;left:0!important;width:200px!important;height:48px!important;z-index:10!important;cursor:move!important;background:transparent!important;touch-action:none!important;"></div>
<iframe id="xiaobaix-story-outline-iframe" class="xiaobaix-iframe" src="${src}" style="width:100%!important;height:100%!important;border:none!important;background:#f4f4f4!important;"></iframe>
<div class="xb-so-resize-handle" style="position:absolute!important;right:0!important;bottom:0!important;width:24px!important;height:24px!important;cursor:nwse-resize!important;background:linear-gradient(135deg,transparent 50%,rgba(0,0,0,0.2) 50%)!important;border-radius:0 0 12px 0!important;z-index:10!important;touch-action:none!important;"></div>
<div class="xb-so-resize-mobile" style="position:absolute!important;right:0!important;bottom:0!important;width:24px!important;height:24px!important;cursor:nwse-resize!important;display:none!important;z-index:10!important;touch-action:none!important;background:linear-gradient(135deg,transparent 50%,rgba(0,0,0,0.2) 50%)!important;border-radius:0 0 12px 0!important;"></div>
</div></div>`;
export const MOBILE_LAYOUT_STYLE = 'position:absolute!important;left:0!important;right:0!important;top:0!important;bottom:auto!important;width:100%!important;height:40vh!important;transform:none!important;z-index:1!important;pointer-events:auto!important;border-radius:0 0 16px 16px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';
export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
<div id="xiaobai_template_editor">
<div class="xiaobai_template_editor">
<h3 class="flex-container justifyCenter alignItemsBaseline">
<strong>模板编辑器</strong>
</h3>
<hr />
<div class="flex-container flexFlowColumn">
<div class="flex1">
<label for="fixed_text_custom_regex" class="title_restorable">
<small>自定义正则表达式</small>
</label>
<div>
<input id="fixed_text_custom_regex" class="text_pole textarea_compact" type="text"
placeholder="\\[([^\\]]+)\\]([\\s\\S]*?)\\[\\/\\1\\]" />
</div>
<div class="flex-container" style="margin-top: 6px;">
<label class="checkbox_label">
<input type="checkbox" id="disable_parsers" />
<span>文本不使用插件预设的正则及格式解析器</span>
</label>
</div>
</div>
</div>
<hr />
<div class="flex-container flexFlowColumn">
<div class="flex1">
<label class="title_restorable">
<small>消息范围限制</small>
</label>
<div class="flex-container" style="margin-top: 10px;">
<label class="checkbox_label">
<input type="checkbox" id="skip_first_message" />
<span>首条消息不插入模板</span>
</label>
</div>
<div class="flex-container">
<label class="checkbox_label">
<input type="checkbox" id="limit_to_recent_messages" />
<span>仅在最后几条消息中生效</span>
</label>
</div>
<div class="flex-container" style="margin-top: 10px;">
<label for="recent_message_count" style="margin-right: 10px;">消息数量:</label>
<input id="recent_message_count" class="text_pole" type="number" min="1" max="50" value="5"
style="width: 80px; max-height: 2.3vh;" />
</div>
</div>
</div>
<hr />
<div class="flex-container flexFlowColumn">
<label for="fixed_text_template" class="title_restorable">
<small>模板内容</small>
</label>
<div>
<textarea id="fixed_text_template" class="text_pole textarea_compact" rows="3"
placeholder="例如hi[[var1]], hello[[var2]], xx[[profile1]]" style="min-height: 20vh;"></textarea>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,686 @@
/**
* @file modules/variables/varevent-editor.js
* @description 条件规则编辑器与 varevent 运行时(常驻模块)
*/
import { getContext, extension_settings } from "../../../../../extensions.js";
import { getLocalVariable } from "../../../../../variables.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
import { normalizePath, lwbSplitPathWithBrackets } from "../../core/variable-path.js";
import { replaceXbGetVarInString } from "./var-commands.js";
const MODULE_ID = 'vareventEditor';
const LWB_EXT_ID = 'LittleWhiteBox';
const LWB_VAREVENT_PROMPT_KEY = 'LWB_varevent_display';
const EDITOR_STYLES_ID = 'lwb-varevent-editor-styles';
const TAG_RE_VAREVENT = /<\s*varevent[^>]*>([\s\S]*?)<\s*\/\s*varevent\s*>/gi;
const OP_ALIASES = {
set: ['set', '记下', '記下', '记录', '記錄', '录入', '錄入', 'record'],
push: ['push', '添入', '增录', '增錄', '追加', 'append'],
bump: ['bump', '推移', '变更', '變更', '调整', '調整', 'adjust'],
del: ['del', '遗忘', '遺忘', '抹去', '删除', '刪除', 'erase'],
};
const OP_MAP = {};
for (const [k, arr] of Object.entries(OP_ALIASES)) for (const a of arr) OP_MAP[a.toLowerCase()] = k;
const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const ALL_OP_WORDS = Object.values(OP_ALIASES).flat();
const OP_WORDS_PATTERN = ALL_OP_WORDS.map(reEscape).sort((a, b) => b.length - a.length).join('|');
const TOP_OP_RE = new RegExp(`^(${OP_WORDS_PATTERN})\\s*:\\s*$`, 'i');
let events = null;
let initialized = false;
let origEmitMap = new WeakMap();
function debounce(fn, wait = 100) { let t = null; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(null, args), wait); }; }
function stripYamlInlineComment(s) {
const text = String(s ?? ''); if (!text) return '';
let inSingle = false, inDouble = false, escaped = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inSingle) { if (ch === "'") { if (text[i + 1] === "'") { i++; continue; } inSingle = false; } continue; }
if (inDouble) { if (escaped) { escaped = false; continue; } if (ch === '\\') { escaped = true; continue; } if (ch === '"') inDouble = false; continue; }
if (ch === "'") { inSingle = true; continue; }
if (ch === '"') { inDouble = true; continue; }
if (ch === '#') { const prev = i > 0 ? text[i - 1] : ''; if (i === 0 || /\s/.test(prev)) return text.slice(0, i); }
}
return text;
}
function getActiveCharacter() {
try {
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return null;
return (ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null)) || null;
} catch { return null; }
}
function readCharExtBumpAliases() {
try {
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return {};
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
const bump = char?.data?.extensions?.[LWB_EXT_ID]?.variablesCore?.bumpAliases;
if (bump && typeof bump === 'object') return bump;
const legacy = char?.extensions?.[LWB_EXT_ID]?.variablesCore?.bumpAliases;
if (legacy && typeof legacy === 'object') { writeCharExtBumpAliases(legacy); return legacy; }
return {};
} catch { return {}; }
}
async function writeCharExtBumpAliases(newStore) {
try {
const ctx = getContext(); const id = ctx?.characterId ?? ctx?.this_chid; if (id == null) return;
if (typeof ctx?.writeExtensionField === 'function') {
await ctx.writeExtensionField(id, LWB_EXT_ID, { variablesCore: { bumpAliases: structuredClone(newStore || {}) } });
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
if (char) {
char.data = char.data && typeof char.data === 'object' ? char.data : {};
char.data.extensions = char.data.extensions && typeof char.data.extensions === 'object' ? char.data.extensions : {};
const ns = (char.data.extensions[LWB_EXT_ID] ||= {});
ns.variablesCore = ns.variablesCore && typeof ns.variablesCore === 'object' ? ns.variablesCore : {};
ns.variablesCore.bumpAliases = structuredClone(newStore || {});
}
typeof ctx?.saveCharacter === 'function' ? await ctx.saveCharacter() : ctx?.saveCharacterDebounced?.();
return;
}
const char = ctx?.getCharacter?.(id) ?? (Array.isArray(ctx?.characters) ? ctx.characters[id] : null);
if (char) {
char.data = char.data && typeof char.data === 'object' ? char.data : {};
char.data.extensions = char.data.extensions && typeof char.data.extensions === 'object' ? char.data.extensions : {};
const ns = (char.data.extensions[LWB_EXT_ID] ||= {});
ns.variablesCore = ns.variablesCore && typeof ns.variablesCore === 'object' ? ns.variablesCore : {};
ns.variablesCore.bumpAliases = structuredClone(newStore || {});
}
typeof ctx?.saveCharacter === 'function' ? await ctx.saveCharacter() : ctx?.saveCharacterDebounced?.();
} catch {}
}
export function getBumpAliasStore() { return readCharExtBumpAliases(); }
export async function setBumpAliasStore(newStore) { await writeCharExtBumpAliases(newStore); }
export async function clearBumpAliasStore() { await writeCharExtBumpAliases({}); }
function getBumpAliasMap() { try { return getBumpAliasStore(); } catch { return {}; } }
function matchAlias(varOrKey, rhs) {
const map = getBumpAliasMap();
for (const scope of [map._global || {}, map[varOrKey] || {}]) {
for (const [k, v] of Object.entries(scope)) {
if (k.startsWith('/') && k.lastIndexOf('/') > 0) {
const last = k.lastIndexOf('/');
try { if (new RegExp(k.slice(1, last), k.slice(last + 1)).test(rhs)) return Number(v); } catch {}
} else if (rhs === k) return Number(v);
}
}
return null;
}
export function preprocessBumpAliases(innerText) {
const lines = String(innerText || '').split(/\r?\n/), out = [];
let inBump = false; const indentOf = (s) => s.length - s.trimStart().length;
const stack = []; let currentVarRoot = '';
for (let i = 0; i < lines.length; i++) {
const raw = lines[i], t = raw.trim();
if (!t) { out.push(raw); continue; }
const ind = indentOf(raw), mTop = TOP_OP_RE.exec(t);
if (mTop && ind === 0) { const opKey = OP_MAP[mTop[1].toLowerCase()] || ''; inBump = opKey === 'bump'; stack.length = 0; currentVarRoot = ''; out.push(raw); continue; }
if (!inBump) { out.push(raw); continue; }
while (stack.length && stack[stack.length - 1].indent >= ind) stack.pop();
const mKV = t.match(/^([^:]+):\s*(.*)$/);
if (mKV) {
const key = mKV[1].trim(), val = String(stripYamlInlineComment(mKV[2])).trim();
const parentPath = stack.length ? stack[stack.length - 1].path : '', curPath = parentPath ? `${parentPath}.${key}` : key;
if (val === '') { stack.push({ indent: ind, path: curPath }); if (!parentPath) currentVarRoot = key; out.push(raw); continue; }
let rhs = val.replace(/^["']|["']$/g, '');
const num = matchAlias(key, rhs) ?? matchAlias(currentVarRoot, rhs) ?? matchAlias('', rhs);
out.push(num !== null && Number.isFinite(num) ? raw.replace(/:\s*.*$/, `: ${num}`) : raw); continue;
}
const mArr = t.match(/^\-\s*(.+)$/);
if (mArr) {
let rhs = String(stripYamlInlineComment(mArr[1])).trim().replace(/^["']|["']$/g, '');
const leafKey = stack.length ? stack[stack.length - 1].path.split('.').pop() : '';
const num = matchAlias(leafKey || currentVarRoot, rhs) ?? matchAlias(currentVarRoot, rhs) ?? matchAlias('', rhs);
out.push(num !== null && Number.isFinite(num) ? raw.replace(/-\s*.*$/, `- ${num}`) : raw); continue;
}
out.push(raw);
}
return out.join('\n');
}
export function parseVareventEvents(innerText) {
const evts = [], lines = String(innerText || '').split(/\r?\n/);
let cur = null;
const flush = () => { if (cur) { evts.push(cur); cur = null; } };
const isStopLine = (t) => !t ? false : /^\[\s*event\.[^\]]+]\s*$/i.test(t) || /^(condition|display|js_execute)\s*:/i.test(t) || /^<\s*\/\s*varevent\s*>/i.test(t);
const findUnescapedQuote = (str, q) => { for (let i = 0; i < str.length; i++) if (str[i] === q && str[i - 1] !== '\\') return i; return -1; };
for (let i = 0; i < lines.length; i++) {
const raw = lines[i], line = raw.trim(); if (!line) continue;
const header = /^\[\s*event\.([^\]]+)]\s*$/i.exec(line);
if (header) { flush(); cur = { id: String(header[1]).trim() }; continue; }
const m = /^(condition|display|js_execute)\s*:\s*(.*)$/i.exec(line);
if (m) {
const key = m[1].toLowerCase(); let valPart = m[2] ?? ''; if (!cur) cur = {};
let value = ''; const ltrim = valPart.replace(/^\s+/, ''), firstCh = ltrim[0];
if (firstCh === '"' || firstCh === "'") {
const quote = firstCh; let after = ltrim.slice(1), endIdx = findUnescapedQuote(after, quote);
if (endIdx !== -1) value = after.slice(0, endIdx);
else { value = after + '\n'; while (++i < lines.length) { const ln = lines[i], pos = findUnescapedQuote(ln, quote); if (pos !== -1) { value += ln.slice(0, pos); break; } value += ln + '\n'; } }
value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
} else { value = valPart; let j = i + 1; while (j < lines.length) { const nextTrim = lines[j].trim(); if (isStopLine(nextTrim)) break; value += '\n' + lines[j]; j++; } i = j - 1; }
if (key === 'condition') cur.condition = value; else if (key === 'display') cur.display = value; else if (key === 'js_execute') cur.js = value;
}
}
flush(); return evts;
}
export function evaluateCondition(expr) {
const isNumericLike = (v) => v != null && /^-?\d+(?:\.\d+)?$/.test(String(v).trim());
function VAR(path) {
try {
const p = String(path ?? '').replace(/\[(\d+)\]/g, '.$1'), seg = p.split('.').map(s => s.trim()).filter(Boolean);
if (!seg.length) return ''; const root = getLocalVariable(seg[0]);
if (seg.length === 1) { if (root == null) return ''; return typeof root === 'object' ? JSON.stringify(root) : String(root); }
let obj; if (typeof root === 'string') { try { obj = JSON.parse(root); } catch { return undefined; } } else if (root && typeof root === 'object') obj = root; else return undefined;
let cur = obj; for (let i = 1; i < seg.length; i++) { cur = cur?.[/^\d+$/.test(seg[i]) ? Number(seg[i]) : seg[i]]; if (cur === undefined) return undefined; }
return cur == null ? '' : typeof cur === 'object' ? JSON.stringify(cur) : String(cur);
} catch { return undefined; }
}
const VAL = (t) => String(t ?? '');
function REL(a, op, b) {
if (isNumericLike(a) && isNumericLike(b)) { const A = Number(String(a).trim()), B = Number(String(b).trim()); if (op === '>') return A > B; if (op === '>=') return A >= B; if (op === '<') return A < B; if (op === '<=') return A <= B; }
else { const A = String(a), B = String(b); if (op === '>') return A > B; if (op === '>=') return A >= B; if (op === '<') return A < B; if (op === '<=') return A <= B; }
return false;
}
try {
let processed = expr.replace(/var\(`([^`]+)`\)/g, 'VAR("$1")').replace(/val\(`([^`]+)`\)/g, 'VAL("$1")');
processed = processed.replace(/(VAR\(".*?"\)|VAL\(".*?"\))\s*(>=|<=|>|<)\s*(VAR\(".*?"\)|VAL\(".*?"\))/g, 'REL($1,"$2",$3)');
return !!eval(processed);
} catch { return false; }
}
export async function runJS(code) {
const ctx = getContext();
try {
const STscriptProxy = async (command) => { if (!command) return; if (command[0] !== '/') command = '/' + command; const { executeSlashCommands, substituteParams } = getContext(); return await executeSlashCommands?.(substituteParams ? substituteParams(command) : command, true); };
const fn = new Function('ctx', 'getVar', 'setVar', 'console', 'STscript', `return (async()=>{ ${code}\n })();`);
const getVar = (k) => getLocalVariable(k);
const setVar = (k, v) => { getContext()?.variables?.local?.set?.(k, v); };
return await fn(ctx, getVar, setVar, console, (typeof window !== 'undefined' && window?.STscript) || STscriptProxy);
} catch (err) { console.error('[LWB:runJS]', err); }
}
export async function runST(code) {
try { if (!code) return; if (code[0] !== '/') code = '/' + code; const { executeSlashCommands, substituteParams } = getContext() || {}; return await executeSlashCommands?.(substituteParams ? substituteParams(code) : code, true); }
catch (err) { console.error('[LWB:runST]', err); }
}
async function buildVareventReplacement(innerText, dryRun, executeJs = false) {
try {
const evts = parseVareventEvents(innerText); if (!evts.length) return '';
let chosen = null;
for (let i = evts.length - 1; i >= 0; i--) {
const ev = evts[i], condStr = String(ev.condition ?? '').trim(), condOk = condStr ? evaluateCondition(condStr) : true;
if (!((ev.display && String(ev.display).trim()) || (ev.js && String(ev.js).trim()))) continue;
if (condOk) { chosen = ev; break; }
}
if (!chosen) return '';
let out = chosen.display ? String(chosen.display).replace(/^\n+/, '').replace(/\n+$/, '') : '';
if (!dryRun && executeJs && chosen.js && String(chosen.js).trim()) { try { await runJS(chosen.js); } catch {} }
return out;
} catch { return ''; }
}
export async function replaceVareventInString(text, dryRun = false, executeJs = false) {
if (!text || text.indexOf('<varevent') === -1) return text;
const replaceAsync = async (input, regex, repl) => { let out = '', last = 0; regex.lastIndex = 0; let m; while ((m = regex.exec(input))) { out += input.slice(last, m.index); out += await repl(...m); last = regex.lastIndex; } return out + input.slice(last); };
return await replaceAsync(text, TAG_RE_VAREVENT, (m, inner) => buildVareventReplacement(inner, dryRun, executeJs));
}
export function enqueuePendingVareventBlock(innerText, sourceInfo) {
try { const ctx = getContext(), meta = ctx?.chatMetadata || {}, list = (meta.LWB_PENDING_VAREVENT_BLOCKS ||= []); list.push({ inner: String(innerText || ''), source: sourceInfo || 'unknown', turn: (ctx?.chat?.length ?? 0), ts: Date.now() }); ctx?.saveMetadataDebounced?.(); } catch {}
}
export function drainPendingVareventBlocks() {
try { const ctx = getContext(), meta = ctx?.chatMetadata || {}, list = Array.isArray(meta.LWB_PENDING_VAREVENT_BLOCKS) ? meta.LWB_PENDING_VAREVENT_BLOCKS.slice() : []; meta.LWB_PENDING_VAREVENT_BLOCKS = []; ctx?.saveMetadataDebounced?.(); return list; } catch { return []; }
}
export async function executeQueuedVareventJsAfterTurn() {
const blocks = drainPendingVareventBlocks(); if (!blocks.length) return;
for (const item of blocks) {
try {
const evts = parseVareventEvents(item.inner); if (!evts.length) continue;
let chosen = null;
for (let j = evts.length - 1; j >= 0; j--) { const ev = evts[j], condStr = String(ev.condition ?? '').trim(); if (!(condStr ? evaluateCondition(condStr) : true)) continue; if (!(ev.js && String(ev.js).trim())) continue; chosen = ev; break; }
if (chosen) { try { await runJS(String(chosen.js ?? '').trim()); } catch {} }
} catch {}
}
}
let _scanRunning = false;
async function runImmediateVarEvents() {
if (_scanRunning) return; _scanRunning = true;
try {
const wiList = getContext()?.world_info || [];
for (const entry of wiList) {
const content = String(entry?.content ?? ''); if (!content || content.indexOf('<varevent') === -1) continue;
TAG_RE_VAREVENT.lastIndex = 0; let m;
while ((m = TAG_RE_VAREVENT.exec(content)) !== null) {
const evts = parseVareventEvents(m[1] ?? '');
for (const ev of evts) { if (!(String(ev.condition ?? '').trim() ? evaluateCondition(String(ev.condition ?? '').trim()) : true)) continue; if (String(ev.display ?? '').trim()) await runST(`/sys "${String(ev.display ?? '').trim().replace(/"/g, '\\"')}"`); if (String(ev.js ?? '').trim()) await runJS(String(ev.js ?? '').trim()); }
}
}
} catch {} finally { setTimeout(() => { _scanRunning = false; }, 0); }
}
const runImmediateVarEventsDebounced = debounce(runImmediateVarEvents, 30);
function installWIHiddenTagStripper() {
const ctx = getContext(), ext = ctx?.extensionSettings; if (!ext) return;
ext.regex = Array.isArray(ext.regex) ? ext.regex : [];
ext.regex = ext.regex.filter(r => !['lwb-varevent-stripper', 'lwb-varevent-replacer'].includes(r?.id) && !['LWB_VarEventStripper', 'LWB_VarEventReplacer'].includes(r?.scriptName));
ctx?.saveSettingsDebounced?.();
}
function registerWIEventSystem() {
const { eventSource, event_types: evtTypes } = getContext() || {};
if (evtTypes?.CHAT_COMPLETION_PROMPT_READY) {
const lateChatReplacementHandler = async (data) => {
try {
if (data?.dryRun) return;
const chat = data?.chat;
if (!Array.isArray(chat)) return;
for (const msg of chat) {
if (typeof msg?.content === 'string') {
if (msg.content.includes('<varevent')) {
TAG_RE_VAREVENT.lastIndex = 0;
let mm;
while ((mm = TAG_RE_VAREVENT.exec(msg.content)) !== null) {
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.content');
}
msg.content = await replaceVareventInString(msg.content, false, false);
}
if (msg.content.indexOf('{{xbgetvar::') !== -1) {
msg.content = replaceXbGetVarInString(msg.content);
}
}
if (Array.isArray(msg?.content)) {
for (const part of msg.content) {
if (part?.type === 'text' && typeof part.text === 'string') {
if (part.text.includes('<varevent')) {
TAG_RE_VAREVENT.lastIndex = 0;
let mm;
while ((mm = TAG_RE_VAREVENT.exec(part.text)) !== null) {
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.content[].text');
}
part.text = await replaceVareventInString(part.text, false, false);
}
if (part.text.indexOf('{{xbgetvar::') !== -1) {
part.text = replaceXbGetVarInString(part.text);
}
}
}
}
if (typeof msg?.mes === 'string') {
if (msg.mes.includes('<varevent')) {
TAG_RE_VAREVENT.lastIndex = 0;
let mm;
while ((mm = TAG_RE_VAREVENT.exec(msg.mes)) !== null) {
enqueuePendingVareventBlock(mm[1] ?? '', 'chat.mes');
}
msg.mes = await replaceVareventInString(msg.mes, false, false);
}
if (msg.mes.indexOf('{{xbgetvar::') !== -1) {
msg.mes = replaceXbGetVarInString(msg.mes);
}
}
}
} catch {}
};
try {
if (eventSource && typeof eventSource.makeLast === 'function') {
eventSource.makeLast(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
} else {
events?.on(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
}
} catch {
events?.on(evtTypes.CHAT_COMPLETION_PROMPT_READY, lateChatReplacementHandler);
}
}
if (evtTypes?.GENERATE_AFTER_COMBINE_PROMPTS) {
events?.on(evtTypes.GENERATE_AFTER_COMBINE_PROMPTS, async (data) => {
try {
if (data?.dryRun) return;
if (typeof data?.prompt === 'string') {
if (data.prompt.includes('<varevent')) {
TAG_RE_VAREVENT.lastIndex = 0;
let mm;
while ((mm = TAG_RE_VAREVENT.exec(data.prompt)) !== null) {
enqueuePendingVareventBlock(mm[1] ?? '', 'prompt');
}
data.prompt = await replaceVareventInString(data.prompt, false, false);
}
if (data.prompt.indexOf('{{xbgetvar::') !== -1) {
data.prompt = replaceXbGetVarInString(data.prompt);
}
}
} catch {}
});
}
if (evtTypes?.GENERATION_ENDED) {
events?.on(evtTypes.GENERATION_ENDED, async () => {
try {
getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false);
await executeQueuedVareventJsAfterTurn();
} catch {}
});
}
if (evtTypes?.CHAT_CHANGED) {
events?.on(evtTypes.CHAT_CHANGED, () => {
try {
getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false);
drainPendingVareventBlocks();
runImmediateVarEventsDebounced();
} catch {}
});
}
if (evtTypes?.APP_READY) {
events?.on(evtTypes.APP_READY, () => {
try {
runImmediateVarEventsDebounced();
} catch {}
});
}
}
const LWBVE = { installed: false, obs: null };
function injectEditorStyles() {
if (document.getElementById(EDITOR_STYLES_ID)) return;
const style = document.createElement('style'); style.id = EDITOR_STYLES_ID;
style.textContent = `.lwb-ve-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none}.lwb-ve-modal{width:650px;background:var(--SmartThemeBlurTintColor);border:2px solid var(--SmartThemeBorderColor);border-radius:10px;box-shadow:0 8px 16px var(--SmartThemeShadowColor);pointer-events:auto}.lwb-ve-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move}.lwb-ve-tabs{display:flex;gap:6px;padding:8px 14px;border-bottom:1px solid var(--SmartThemeBorderColor)}.lwb-ve-tab{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:4px 8px;border-radius:6px;opacity:.8}.lwb-ve-tab.active{opacity:1;border-color:var(--crimson70a)}.lwb-ve-page{display:none}.lwb-ve-page.active{display:block}.lwb-ve-body{height:60vh;overflow:auto;padding:10px}.lwb-ve-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor)}.lwb-ve-section{margin:12px 0}.lwb-ve-label{font-size:13px;opacity:.7;margin:6px 0}.lwb-ve-row{gap:8px;align-items:center;margin:4px 0;padding-bottom:10px;border-bottom:1px dashed var(--SmartThemeBorderColor);display:flex;flex-wrap:wrap}.lwb-ve-input,.lwb-ve-text{box-sizing:border-box;background:var(--SmartThemeShadowColor);color:inherit;border:1px solid var(--SmartThemeUserMesBlurTintColor);border-radius:6px;padding:6px 8px}.lwb-ve-text{min-height:64px;resize:vertical;width:100%}.lwb-ve-input{width:260px}.lwb-ve-mini{width:70px!important;margin:0}.lwb-ve-op,.lwb-ve-ctype option{text-align:center}.lwb-ve-lop{width:70px!important;text-align:center}.lwb-ve-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px}.lwb-ve-btn.primary{background:var(--crimson70a)}.lwb-ve-event{border:1px dashed var(--SmartThemeBorderColor);border-radius:8px;padding:10px;margin:10px 0}.lwb-ve-event-title{font-weight:600;display:flex;align-items:center;gap:8px}.lwb-ve-close{cursor:pointer}.lwb-var-editor-button.right_menu_button{display:inline-flex;align-items:center;margin-left:10px;transform:scale(1.5)}.lwb-ve-vals,.lwb-ve-varrhs{align-items:center;display:inline-flex;gap:6px}.lwb-ve-delval{transform:scale(.5)}.lwb-act-type{width:200px!important}.lwb-ve-condgroups{display:flex;flex-direction:column;gap:10px}.lwb-ve-condgroup{border:1px solid var(--SmartThemeBorderColor);border-radius:8px;padding:8px}.lwb-ve-group-title{display:flex;align-items:center;gap:8px;margin-bottom:6px}.lwb-ve-group-name{font-weight:600}.lwb-ve-group-lop{width:70px!important;text-align:center}.lwb-ve-add-group{margin-top:6px}@media (max-width:999px){.lwb-ve-overlay{position:absolute;inset:0;align-items:flex-start}.lwb-ve-modal{width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0}}`;
document.head.appendChild(style);
}
const U = {
qa: (root, sel) => Array.from((root || document).querySelectorAll(sel)),
el: (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; },
setActive(listLike, idx) { (Array.isArray(listLike) ? listLike : U.qa(document, listLike)).forEach((el, i) => el.classList.toggle('active', i === idx)); },
toast: { ok: (m) => window?.toastr?.success?.(m), warn: (m) => window?.toastr?.warning?.(m), err: (m) => window?.toastr?.error?.(m) },
drag(modal, overlay, header) {
try { modal.style.position = 'absolute'; modal.style.left = '50%'; modal.style.top = '50%'; modal.style.transform = 'translate(-50%,-50%)'; } catch {}
let dragging = false, sx = 0, sy = 0, sl = 0, st = 0;
const onDown = (e) => { if (!(e instanceof PointerEvent) || e.button !== 0) return; dragging = true; const r = modal.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); modal.style.left = (r.left - ro.left) + 'px'; modal.style.top = (r.top - ro.top) + 'px'; modal.style.transform = ''; sx = e.clientX; sy = e.clientY; sl = parseFloat(modal.style.left) || 0; st = parseFloat(modal.style.top) || 0; window.addEventListener('pointermove', onMove, { passive: true }); window.addEventListener('pointerup', onUp, { once: true }); e.preventDefault(); };
const onMove = (e) => { if (!dragging) return; let nl = sl + e.clientX - sx, nt = st + e.clientY - sy; const maxL = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth, maxT = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight; modal.style.left = Math.max(0, Math.min(maxL, nl)) + 'px'; modal.style.top = Math.max(0, Math.min(maxT, nt)) + 'px'; };
const onUp = () => { dragging = false; window.removeEventListener('pointermove', onMove); };
header.addEventListener('pointerdown', onDown);
},
mini(innerHTML, title = '编辑器') {
const wrap = U.el('div', 'lwb-ve-overlay'), modal = U.el('div', 'lwb-ve-modal'); modal.style.maxWidth = '720px'; modal.style.pointerEvents = 'auto'; modal.style.zIndex = '10010'; wrap.appendChild(modal);
const header = U.el('div', 'lwb-ve-header', `<span>${title}</span><span class="lwb-ve-close">✕</span>`), body = U.el('div', 'lwb-ve-body', innerHTML), footer = U.el('div', 'lwb-ve-footer');
const btnCancel = U.el('button', 'lwb-ve-btn', '取消'), btnOk = U.el('button', 'lwb-ve-btn primary', '生成');
footer.append(btnCancel, btnOk); modal.append(header, body, footer); U.drag(modal, wrap, header);
btnCancel.addEventListener('click', () => wrap.remove()); header.querySelector('.lwb-ve-close')?.addEventListener('click', () => wrap.remove());
document.body.appendChild(wrap); return { wrap, modal, body, btnOk, btnCancel };
},
};
const P = {
stripOuter(s) { let t = String(s || '').trim(); if (!t.startsWith('(') || !t.endsWith(')')) return t; let i = 0, d = 0, q = null; while (i < t.length) { const c = t[i]; if (q) { if (c === q && t[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') d++; else if (c === ')') d--; i++; } return d === 0 ? t.slice(1, -1).trim() : t; },
stripOuterWithFlag(s) { let t = String(s || '').trim(); if (!t.startsWith('(') || !t.endsWith(')')) return { text: t, wrapped: false }; let i = 0, d = 0, q = null; while (i < t.length) { const c = t[i]; if (q) { if (c === q && t[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') d++; else if (c === ')') d--; i++; } return d === 0 ? { text: t.slice(1, -1).trim(), wrapped: true } : { text: t, wrapped: false }; },
splitTopWithOps(s) { const out = []; let i = 0, start = 0, d = 0, q = null, pendingOp = null; while (i < s.length) { const c = s[i]; if (q) { if (c === q && s[i - 1] !== '\\') q = null; i++; continue; } if (c === '"' || c === "'" || c === '`') { q = c; i++; continue; } if (c === '(') { d++; i++; continue; } if (c === ')') { d--; i++; continue; } if (d === 0 && (s.slice(i, i + 2) === '&&' || s.slice(i, i + 2) === '||')) { const seg = s.slice(start, i).trim(); if (seg) out.push({ op: pendingOp, expr: seg }); pendingOp = s.slice(i, i + 2); i += 2; start = i; continue; } i++; } const tail = s.slice(start).trim(); if (tail) out.push({ op: pendingOp, expr: tail }); return out; },
parseComp(s) { const t = P.stripOuter(s), m = t.match(/^var\(\s*([`'"])([\s\S]*?)\1\s*\)\s*(==|!=|>=|<=|>|<)\s*(val|var)\(\s*([`'"])([\s\S]*?)\5\s*\)$/); if (!m) return null; return { lhs: m[2], op: m[3], rhsIsVar: m[4] === 'var', rhs: m[6] }; },
hasBinary: (s) => /\|\||&&/.test(s),
paren: (s) => (s.startsWith('(') && s.endsWith(')')) ? s : `(${s})`,
wrapBack: (s) => { const t = String(s || '').trim(); return /^([`'"]).*\1$/.test(t) ? t : '`' + t.replace(/`/g, '\\`') + '`'; },
buildVar: (name) => `var(${P.wrapBack(name)})`,
buildVal(v) { const t = String(v || '').trim(); return /^([`'"]).*\1$/.test(t) ? `val(${t})` : `val(${P.wrapBack(t)})`; },
};
function buildSTscriptFromActions(actionList) {
const parts = [], jsEsc = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'), plain = (s) => String(s ?? '').trim();
for (const a of actionList || []) {
switch (a.type) {
case 'var.set': parts.push(`/setvar key=${plain(a.key)} ${plain(a.value)}`); break;
case 'var.bump': parts.push(`/addvar key=${plain(a.key)} ${Number(a.delta) || 0}`); break;
case 'var.del': parts.push(`/flushvar ${plain(a.key)}`); break;
case 'wi.enableUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=disable 0`); break;
case 'wi.disableUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=disable 1`); break;
case 'wi.setContentUID': parts.push(`/setentryfield file=${plain(a.file)} uid=${plain(a.uid)} field=content ${plain(a.content)}`); break;
case 'wi.createContent': parts.push(plain(a.content) ? `/createentry file=${plain(a.file)} key=${plain(a.key)} ${plain(a.content)}` : `/createentry file=${plain(a.file)} key=${plain(a.key)}`); parts.push(`/setentryfield file=${plain(a.file)} uid={{pipe}} field=constant 1`); break;
case 'qr.run': parts.push(`/run ${a.preset ? `${plain(a.preset)}.` : ''}${plain(a.label)}`); break;
case 'custom.st': if (a.script) parts.push(...a.script.split('\n').map(s => s.trim()).filter(Boolean).map(c => c.startsWith('/') ? c : '/' + c)); break;
}
}
return 'STscript(`' + jsEsc(parts.join(' | ')) + '`)';
}
const UI = {
getEventBlockHTML(index) {
return `<div class="lwb-ve-event-title">事件 #<span class="lwb-ve-idx">${index}</span><span class="lwb-ve-close" title="删除事件" style="margin-left:auto;">✕</span></div><div class="lwb-ve-section"><div class="lwb-ve-label">执行条件</div><div class="lwb-ve-condgroups"></div><button type="button" class="lwb-ve-btn lwb-ve-add-group"><i class="fa-solid fa-plus"></i>添加条件小组</button></div><div class="lwb-ve-section"><div class="lwb-ve-label">将显示世界书内容(可选)</div><textarea class="lwb-ve-text lwb-ve-display" placeholder="例如:&lt;Info&gt;……&lt;/Info&gt;"></textarea></div><div class="lwb-ve-section"><div class="lwb-ve-label">将执行stscript命令或JS代码可选</div><textarea class="lwb-ve-text lwb-ve-js" placeholder="stscript:/setvar key=foo 1 | /run SomeQR 或 直接JS"></textarea><div style="margin-top:6px; display:flex; gap:8px; flex-wrap:wrap;"><button type="button" class="lwb-ve-btn lwb-ve-gen-st">常用st控制</button></div></div>`;
},
getConditionRowHTML() {
return `<select class="lwb-ve-input lwb-ve-mini lwb-ve-lop" style="display:none;"><option value="||">或</option><option value="&&" selected>和</option></select><select class="lwb-ve-input lwb-ve-mini lwb-ve-ctype"><option value="vv">比较值</option><option value="vvv">比较变量</option></select><input class="lwb-ve-input lwb-ve-var" placeholder="变量名称"/><select class="lwb-ve-input lwb-ve-mini lwb-ve-op"><option value="==">等于</option><option value="!=">不等于</option><option value=">=">大于或等于</option><option value="<=">小于或等于</option><option value=">">大于</option><option value="<">小于</option></select><span class="lwb-ve-vals"><span class="lwb-ve-valwrap"><input class="lwb-ve-input lwb-ve-val" placeholder="值"/></span></span><span class="lwb-ve-varrhs" style="display:none;"><span class="lwb-ve-valvarwrap"><input class="lwb-ve-input lwb-ve-valvar" placeholder="变量B名称"/></span></span><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`;
},
makeConditionGroup() {
const g = U.el('div', 'lwb-ve-condgroup', `<div class="lwb-ve-group-title"><select class="lwb-ve-input lwb-ve-mini lwb-ve-group-lop" style="display:none;"><option value="&&">和</option><option value="||">或</option></select><span class="lwb-ve-group-name">小组</span><span style="flex:1 1 auto;"></span><button type="button" class="lwb-ve-btn ghost lwb-ve-del-group">删除小组</button></div><div class="lwb-ve-conds"></div><button type="button" class="lwb-ve-btn lwb-ve-add-cond"><i class="fa-solid fa-plus"></i>添加条件</button>`);
const conds = g.querySelector('.lwb-ve-conds');
g.querySelector('.lwb-ve-add-cond')?.addEventListener('click', () => { try { UI.addConditionRow(conds, {}); } catch {} });
g.querySelector('.lwb-ve-del-group')?.addEventListener('click', () => g.remove());
return g;
},
refreshLopDisplay(container) { U.qa(container, '.lwb-ve-row').forEach((r, idx) => { const lop = r.querySelector('.lwb-ve-lop'); if (!lop) return; lop.style.display = idx === 0 ? 'none' : ''; if (idx > 0 && !lop.value) lop.value = '&&'; }); },
setupConditionRow(row, onRowsChanged) {
row.querySelector('.lwb-ve-del')?.addEventListener('click', () => { row.remove(); onRowsChanged?.(); });
const ctype = row.querySelector('.lwb-ve-ctype'), vals = row.querySelector('.lwb-ve-vals'), rhs = row.querySelector('.lwb-ve-varrhs');
ctype?.addEventListener('change', () => { if (ctype.value === 'vv') { vals.style.display = 'inline-flex'; rhs.style.display = 'none'; } else { vals.style.display = 'none'; rhs.style.display = 'inline-flex'; } });
},
createConditionRow(params, onRowsChanged) {
const { lop, lhs, op, rhsIsVar, rhs } = params || {};
const row = U.el('div', 'lwb-ve-row', UI.getConditionRowHTML());
const lopSel = row.querySelector('.lwb-ve-lop'); if (lopSel) { if (lop == null) { lopSel.style.display = 'none'; lopSel.value = '&&'; } else { lopSel.style.display = ''; lopSel.value = String(lop || '&&'); } }
const varInp = row.querySelector('.lwb-ve-var'); if (varInp && lhs != null) varInp.value = String(lhs);
const opSel = row.querySelector('.lwb-ve-op'); if (opSel && op != null) opSel.value = String(op);
const ctypeSel = row.querySelector('.lwb-ve-ctype'), valsWrap = row.querySelector('.lwb-ve-vals'), varRhsWrap = row.querySelector('.lwb-ve-varrhs');
if (ctypeSel && valsWrap && varRhsWrap && (rhsIsVar != null || rhs != null)) {
if (rhsIsVar) { ctypeSel.value = 'vvv'; valsWrap.style.display = 'none'; varRhsWrap.style.display = 'inline-flex'; const rhsInp = row.querySelector('.lwb-ve-varrhs .lwb-ve-valvar'); if (rhsInp && rhs != null) rhsInp.value = String(rhs); }
else { ctypeSel.value = 'vv'; valsWrap.style.display = 'inline-flex'; varRhsWrap.style.display = 'none'; const rhsInp = row.querySelector('.lwb-ve-vals .lwb-ve-val'); if (rhsInp && rhs != null) rhsInp.value = String(rhs); }
}
UI.setupConditionRow(row, onRowsChanged || null); return row;
},
addConditionRow(container, params) { const row = UI.createConditionRow(params, () => UI.refreshLopDisplay(container)); container.appendChild(row); UI.refreshLopDisplay(container); return row; },
parseConditionIntoUI(block, condStr) {
try {
const groupWrap = block.querySelector('.lwb-ve-condgroups'); if (!groupWrap) return; groupWrap.innerHTML = '';
const top = P.splitTopWithOps(condStr);
top.forEach((seg, idxSeg) => {
const { text } = P.stripOuterWithFlag(seg.expr), g = UI.makeConditionGroup(); groupWrap.appendChild(g);
const glopSel = g.querySelector('.lwb-ve-group-lop'); if (glopSel) { glopSel.style.display = idxSeg === 0 ? 'none' : ''; if (idxSeg > 0) glopSel.value = seg.op || '&&'; }
const name = g.querySelector('.lwb-ve-group-name'); if (name) name.textContent = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'[idxSeg] || (idxSeg + 1)) + ' 小组';
const rows = P.splitTopWithOps(P.stripOuter(text)); let first = true; const cw = g.querySelector('.lwb-ve-conds');
rows.forEach(r => { const comp = P.parseComp(r.expr); if (!comp) return; UI.addConditionRow(cw, { lop: first ? null : (r.op || '&&'), lhs: comp.lhs, op: comp.op, rhsIsVar: comp.rhsIsVar, rhs: comp.rhs }); first = false; });
});
} catch {}
},
createEventBlock(index) {
const block = U.el('div', 'lwb-ve-event', UI.getEventBlockHTML(index));
block.querySelector('.lwb-ve-event-title .lwb-ve-close')?.addEventListener('click', () => { block.remove(); block.dispatchEvent(new CustomEvent('lwb-refresh-idx', { bubbles: true })); });
const groupWrap = block.querySelector('.lwb-ve-condgroups'), addGroupBtn = block.querySelector('.lwb-ve-add-group');
const refreshGroupOpsAndNames = () => { U.qa(groupWrap, '.lwb-ve-condgroup').forEach((g, i) => { const glop = g.querySelector('.lwb-ve-group-lop'); if (glop) { glop.style.display = i === 0 ? 'none' : ''; if (i > 0 && !glop.value) glop.value = '&&'; } const nm = g.querySelector('.lwb-ve-group-name'); if (nm) nm.textContent = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ'[i] || (i + 1)) + ' 小组'; }); };
const createGroup = () => { const g = UI.makeConditionGroup(); UI.addConditionRow(g.querySelector('.lwb-ve-conds'), {}); g.querySelector('.lwb-ve-del-group')?.addEventListener('click', () => { g.remove(); refreshGroupOpsAndNames(); }); return g; };
addGroupBtn.addEventListener('click', () => { groupWrap.appendChild(createGroup()); refreshGroupOpsAndNames(); });
groupWrap.appendChild(createGroup()); refreshGroupOpsAndNames();
block.querySelector('.lwb-ve-gen-st')?.addEventListener('click', () => openActionBuilder(block));
return block;
},
refreshEventIndices(eventsWrap) {
U.qa(eventsWrap, '.lwb-ve-event').forEach((el, i) => {
const idxEl = el.querySelector('.lwb-ve-idx'); if (!idxEl) return;
idxEl.textContent = String(i + 1); idxEl.style.cursor = 'pointer'; idxEl.title = '点击修改显示名称';
if (!idxEl.dataset.clickbound) { idxEl.dataset.clickbound = '1'; idxEl.addEventListener('click', () => { const cur = idxEl.textContent || '', name = prompt('输入事件显示名称:', cur) ?? ''; if (name) idxEl.textContent = name; }); }
});
},
processEventBlock(block, idx) {
const displayName = String(block.querySelector('.lwb-ve-idx')?.textContent || '').trim();
const id = (displayName && /^\w[\w.-]*$/.test(displayName)) ? displayName : String(idx + 1).padStart(4, '0');
const lines = [`[event.${id}]`]; let condStr = '', hasAny = false;
const groups = U.qa(block, '.lwb-ve-condgroup');
for (let gi = 0; gi < groups.length; gi++) {
const g = groups[gi], rows = U.qa(g, '.lwb-ve-conds .lwb-ve-row'); let groupExpr = '', groupHas = false;
for (const r of rows) {
const v = r.querySelector('.lwb-ve-var')?.value?.trim?.() || '', op = r.querySelector('.lwb-ve-op')?.value || '==', ctype = r.querySelector('.lwb-ve-ctype')?.value || 'vv'; if (!v) continue;
let rowExpr = '';
if (ctype === 'vv') { const ins = U.qa(r, '.lwb-ve-vals .lwb-ve-val'), exprs = []; for (const inp of ins) { const val = (inp?.value || '').trim(); if (!val) continue; exprs.push(`${P.buildVar(v)} ${op} ${P.buildVal(val)}`); } if (exprs.length === 1) rowExpr = exprs[0]; else if (exprs.length > 1) rowExpr = '(' + exprs.join(' || ') + ')'; }
else { const ins = U.qa(r, '.lwb-ve-varrhs .lwb-ve-valvar'), exprs = []; for (const inp of ins) { const rhs = (inp?.value || '').trim(); if (!rhs) continue; exprs.push(`${P.buildVar(v)} ${op} ${P.buildVar(rhs)}`); } if (exprs.length === 1) rowExpr = exprs[0]; else if (exprs.length > 1) rowExpr = '(' + exprs.join(' || ') + ')'; }
if (!rowExpr) continue;
const lop = r.querySelector('.lwb-ve-lop')?.value || '&&';
if (!groupHas) { groupExpr = rowExpr; groupHas = true; } else { if (lop === '&&') { const left = P.hasBinary(groupExpr) ? P.paren(groupExpr) : groupExpr, right = P.hasBinary(rowExpr) ? P.paren(rowExpr) : rowExpr; groupExpr = `${left} && ${right}`; } else { groupExpr = `${groupExpr} || ${(P.hasBinary(rowExpr) ? P.paren(rowExpr) : rowExpr)}`; } }
}
if (!groupHas) continue;
const glop = g.querySelector('.lwb-ve-group-lop')?.value || '&&', wrap = P.hasBinary(groupExpr) ? P.paren(groupExpr) : groupExpr;
if (!hasAny) { condStr = wrap; hasAny = true; } else condStr = glop === '&&' ? `${condStr} && ${wrap}` : `${condStr} || ${wrap}`;
}
const disp = block.querySelector('.lwb-ve-display')?.value ?? '', js = block.querySelector('.lwb-ve-js')?.value ?? '', dispCore = String(disp).replace(/^\n+|\n+$/g, '');
if (!dispCore && !js) return { lines: [] };
if (condStr) lines.push(`condition: ${condStr}`);
if (dispCore !== '') lines.push('display: "' + ('\n' + dispCore + '\n').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"');
if (js !== '') lines.push(`js_execute: ${JSON.stringify(js)}`);
return { lines };
},
};
export function openVarEditor(entryEl, uid) {
const textarea = (uid ? document.getElementById(`world_entry_content_${uid}`) : null) || entryEl?.querySelector?.('textarea[name="content"]');
if (!textarea) { U.toast.warn('未找到内容输入框,请先展开该条目的编辑抽屉'); return; }
const overlay = U.el('div', 'lwb-ve-overlay'), modal = U.el('div', 'lwb-ve-modal'); overlay.appendChild(modal); modal.style.pointerEvents = 'auto'; modal.style.zIndex = '10010';
const header = U.el('div', 'lwb-ve-header', `<span>条件规则编辑器</span><span class="lwb-ve-close">✕</span>`);
const tabs = U.el('div', 'lwb-ve-tabs'), tabsCtrl = U.el('div'); tabsCtrl.style.cssText = 'margin-left:auto;display:inline-flex;gap:6px;';
const btnAddTab = U.el('button', 'lwb-ve-btn', '+组'), btnDelTab = U.el('button', 'lwb-ve-btn ghost', '-组');
tabs.appendChild(tabsCtrl); tabsCtrl.append(btnAddTab, btnDelTab);
const body = U.el('div', 'lwb-ve-body'), footer = U.el('div', 'lwb-ve-footer');
const btnCancel = U.el('button', 'lwb-ve-btn', '取消'), btnOk = U.el('button', 'lwb-ve-btn primary', '确认');
footer.append(btnCancel, btnOk); modal.append(header, tabs, body, footer); U.drag(modal, overlay, header);
const pagesWrap = U.el('div'); body.appendChild(pagesWrap);
const addEventBtn = U.el('button', 'lwb-ve-btn', '<i class="fa-solid fa-plus"></i> 添加事件'); addEventBtn.type = 'button'; addEventBtn.style.cssText = 'background: var(--SmartThemeBlurTintColor); border: 1px solid var(--SmartThemeBorderColor); cursor: pointer; margin-right: 5px;';
const bumpBtn = U.el('button', 'lwb-ve-btn lwb-ve-gen-bump', 'bump数值映射设置');
const tools = U.el('div', 'lwb-ve-toolbar'); tools.append(addEventBtn, bumpBtn); body.appendChild(tools);
bumpBtn.addEventListener('click', () => openBumpAliasBuilder(null));
const wi = document.getElementById('WorldInfo'), wiIcon = document.getElementById('WIDrawerIcon');
const wasPinned = !!wi?.classList.contains('pinnedOpen'); let tempPinned = false;
if (wi && !wasPinned) { wi.classList.add('pinnedOpen'); tempPinned = true; } if (wiIcon && !wiIcon.classList.contains('drawerPinnedOpen')) wiIcon.classList.add('drawerPinnedOpen');
const closeEditor = () => { try { const pinChecked = !!document.getElementById('WI_panel_pin')?.checked; if (tempPinned && !pinChecked) { wi?.classList.remove('pinnedOpen'); wiIcon?.classList.remove('drawerPinnedOpen'); } } catch {} overlay.remove(); };
btnCancel.addEventListener('click', closeEditor); header.querySelector('.lwb-ve-close')?.addEventListener('click', closeEditor);
const TAG_RE = { varevent: /<varevent>([\s\S]*?)<\/varevent>/gi }, originalText = String(textarea.value || ''), vareventBlocks = [];
TAG_RE.varevent.lastIndex = 0; let mm; while ((mm = TAG_RE.varevent.exec(originalText)) !== null) vareventBlocks.push({ inner: mm[1] ?? '' });
const pageInitialized = new Set();
const makePage = () => { const page = U.el('div', 'lwb-ve-page'), eventsWrap = U.el('div'); page.appendChild(eventsWrap); return { page, eventsWrap }; };
const renderPage = (pageIdx) => {
const tabEls = U.qa(tabs, '.lwb-ve-tab'); U.setActive(tabEls, pageIdx);
const current = vareventBlocks[pageIdx], evts = (current && typeof current.inner === 'string') ? (parseVareventEvents(current.inner) || []) : [];
let page = U.qa(pagesWrap, '.lwb-ve-page')[pageIdx]; if (!page) { page = makePage().page; pagesWrap.appendChild(page); }
U.qa(pagesWrap, '.lwb-ve-page').forEach(el => el.classList.remove('active')); page.classList.add('active');
let eventsWrap = page.querySelector(':scope > div'); if (!eventsWrap) { eventsWrap = U.el('div'); page.appendChild(eventsWrap); }
const init = () => {
eventsWrap.innerHTML = '';
if (!evts.length) eventsWrap.appendChild(UI.createEventBlock(1));
else evts.forEach((_ev, i) => { const block = UI.createEventBlock(i + 1); try { const condStr = String(_ev.condition || '').trim(); if (condStr) UI.parseConditionIntoUI(block, condStr); const disp = String(_ev.display || ''), dispEl = block.querySelector('.lwb-ve-display'); if (dispEl) dispEl.value = disp.replace(/^\r?\n/, '').replace(/\r?\n$/, ''); const js = String(_ev.js || ''), jsEl = block.querySelector('.lwb-ve-js'); if (jsEl) jsEl.value = js; } catch {} eventsWrap.appendChild(block); });
UI.refreshEventIndices(eventsWrap); eventsWrap.addEventListener('lwb-refresh-idx', () => UI.refreshEventIndices(eventsWrap));
};
if (!pageInitialized.has(pageIdx)) { init(); pageInitialized.add(pageIdx); } else if (!eventsWrap.querySelector('.lwb-ve-event')) init();
};
pagesWrap._lwbRenderPage = renderPage;
addEventBtn.addEventListener('click', () => { const active = pagesWrap.querySelector('.lwb-ve-page.active'), eventsWrap = active?.querySelector(':scope > div'); if (!eventsWrap) return; eventsWrap.appendChild(UI.createEventBlock(eventsWrap.children.length + 1)); eventsWrap.dispatchEvent(new CustomEvent('lwb-refresh-idx', { bubbles: true })); });
if (vareventBlocks.length === 0) { const tab = U.el('div', 'lwb-ve-tab active', '组 1'); tabs.insertBefore(tab, tabsCtrl); const { page, eventsWrap } = makePage(); pagesWrap.appendChild(page); page.classList.add('active'); eventsWrap.appendChild(UI.createEventBlock(1)); UI.refreshEventIndices(eventsWrap); tab.addEventListener('click', () => { U.qa(tabs, '.lwb-ve-tab').forEach(el => el.classList.remove('active')); tab.classList.add('active'); U.qa(pagesWrap, '.lwb-ve-page').forEach(el => el.classList.remove('active')); page.classList.add('active'); }); }
else { vareventBlocks.forEach((_b, i) => { const tab = U.el('div', 'lwb-ve-tab' + (i === 0 ? ' active' : ''), `${i + 1}`); tab.addEventListener('click', () => renderPage(i)); tabs.insertBefore(tab, tabsCtrl); }); renderPage(0); }
btnAddTab.addEventListener('click', () => { const newIdx = U.qa(tabs, '.lwb-ve-tab').length; vareventBlocks.push({ inner: '' }); const tab = U.el('div', 'lwb-ve-tab', `${newIdx + 1}`); tab.addEventListener('click', () => pagesWrap._lwbRenderPage(newIdx)); tabs.insertBefore(tab, tabsCtrl); pagesWrap._lwbRenderPage(newIdx); });
btnDelTab.addEventListener('click', () => { const tabEls = U.qa(tabs, '.lwb-ve-tab'); if (tabEls.length <= 1) { U.toast.warn('至少保留一组'); return; } const activeIdx = tabEls.findIndex(t => t.classList.contains('active')), idx = activeIdx >= 0 ? activeIdx : 0; U.qa(pagesWrap, '.lwb-ve-page')[idx]?.remove(); tabEls[idx]?.remove(); vareventBlocks.splice(idx, 1); const rebind = U.qa(tabs, '.lwb-ve-tab'); rebind.forEach((t, i) => { const nt = t.cloneNode(true); nt.textContent = `${i + 1}`; nt.addEventListener('click', () => pagesWrap._lwbRenderPage(i)); tabs.replaceChild(nt, t); }); pagesWrap._lwbRenderPage(Math.max(0, Math.min(idx, rebind.length - 1))); });
btnOk.addEventListener('click', () => {
const pageEls = U.qa(pagesWrap, '.lwb-ve-page'); if (pageEls.length === 0) { closeEditor(); return; }
const builtBlocks = [], seenIds = new Set();
pageEls.forEach((p) => { const wrap = p.querySelector(':scope > div'), blks = wrap ? U.qa(wrap, '.lwb-ve-event') : [], lines = ['<varevent>']; let hasEvents = false; blks.forEach((b, j) => { const r = UI.processEventBlock(b, j); if (r.lines.length > 0) { const idLine = r.lines[0], mm = idLine.match(/^\[\s*event\.([^\]]+)\]/i), id = mm ? mm[1] : `evt_${j + 1}`; let use = id, k = 2; while (seenIds.has(use)) use = `${id}_${k++}`; if (use !== id) r.lines[0] = `[event.${use}]`; seenIds.add(use); lines.push(...r.lines); hasEvents = true; } }); if (hasEvents) { lines.push('</varevent>'); builtBlocks.push(lines.join('\n')); } });
const oldVal = textarea.value || '', originals = [], RE = { varevent: /<varevent>([\s\S]*?)<\/varevent>/gi }; RE.varevent.lastIndex = 0; let m; while ((m = RE.varevent.exec(oldVal)) !== null) originals.push({ start: m.index, end: RE.varevent.lastIndex });
let acc = '', pos = 0; const minLen = Math.min(originals.length, builtBlocks.length);
for (let i = 0; i < originals.length; i++) { const { start, end } = originals[i]; acc += oldVal.slice(pos, start); if (i < minLen) acc += builtBlocks[i]; pos = end; } acc += oldVal.slice(pos);
if (builtBlocks.length > originals.length) { const extras = builtBlocks.slice(originals.length).join('\n\n'); acc = acc.replace(/\s*$/, ''); if (acc && !/(?:\r?\n){2}$/.test(acc)) acc += (/\r?\n$/.test(acc) ? '' : '\n') + '\n'; acc += extras; }
acc = acc.replace(/(?:\r?\n){3,}/g, '\n\n'); textarea.value = acc; try { window?.jQuery?.(textarea)?.trigger?.('input'); } catch {}
U.toast.ok('已更新条件规则到该世界书条目'); closeEditor();
});
document.body.appendChild(overlay);
}
export function openActionBuilder(block) {
const TYPES = [
{ value: 'var.set', label: '变量: set', template: `<input class="lwb-ve-input" placeholder="变量名 key"/><input class="lwb-ve-input" placeholder="值 value"/>` },
{ value: 'var.bump', label: '变量: bump(+/-)', template: `<input class="lwb-ve-input" placeholder="变量名 key"/><input class="lwb-ve-input" placeholder="增量(整数,可负) delta"/>` },
{ value: 'var.del', label: '变量: del', template: `<input class="lwb-ve-input" placeholder="变量名 key"/>` },
{ value: 'wi.enableUID', label: '世界书: 启用条目(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file必填"/><input class="lwb-ve-input" placeholder="条目UID必填"/>` },
{ value: 'wi.disableUID', label: '世界书: 禁用条目(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file必填"/><input class="lwb-ve-input" placeholder="条目UID必填"/>` },
{ value: 'wi.setContentUID', label: '世界书: 设置内容(UID)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file必填"/><input class="lwb-ve-input" placeholder="条目UID必填"/><textarea class="lwb-ve-text" rows="3" placeholder="内容 content可多行"></textarea>` },
{ value: 'wi.createContent', label: '世界书: 新建条目(仅内容)', template: `<input class="lwb-ve-input" placeholder="世界书文件名 file必填"/><input class="lwb-ve-input" placeholder="条目 key建议填写"/><textarea class="lwb-ve-text" rows="4" placeholder="新条目内容 content可留空"></textarea>` },
{ value: 'qr.run', label: '快速回复(/run', template: `<input class="lwb-ve-input" placeholder="预设名(可空) preset"/><input class="lwb-ve-input" placeholder="标签label必填"/>` },
{ value: 'custom.st', label: '自定义ST命令', template: `<textarea class="lwb-ve-text" rows="4" placeholder="每行一条斜杠命令"></textarea>` },
];
const ui = U.mini(`<div class="lwb-ve-section"><div class="lwb-ve-label">添加动作</div><div id="lwb-action-list"></div><button type="button" class="lwb-ve-btn" id="lwb-add-action">+动作</button></div>`, '常用st控制');
const list = ui.body.querySelector('#lwb-action-list'), addBtn = ui.body.querySelector('#lwb-add-action');
const addRow = (presetType) => { const row = U.el('div', 'lwb-ve-row'); row.style.alignItems = 'flex-start'; row.innerHTML = `<select class="lwb-ve-input lwb-ve-mini lwb-act-type"></select><div class="lwb-ve-fields" style="flex:1; display:grid; grid-template-columns: 1fr 1fr; gap:6px;"></div><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`; const typeSel = row.querySelector('.lwb-act-type'), fields = row.querySelector('.lwb-ve-fields'); row.querySelector('.lwb-ve-del').addEventListener('click', () => row.remove()); typeSel.innerHTML = TYPES.map(a => `<option value="${a.value}">${a.label}</option>`).join(''); const renderFields = () => { const def = TYPES.find(a => a.value === typeSel.value); fields.innerHTML = def ? def.template : ''; }; typeSel.addEventListener('change', renderFields); if (presetType) typeSel.value = presetType; renderFields(); list.appendChild(row); };
addBtn.addEventListener('click', () => addRow()); addRow();
ui.btnOk.addEventListener('click', () => {
const rows = U.qa(list, '.lwb-ve-row'), actions = [];
for (const r of rows) { const type = r.querySelector('.lwb-act-type')?.value, inputs = U.qa(r, '.lwb-ve-fields .lwb-ve-input, .lwb-ve-fields .lwb-ve-text').map(i => i.value); if (type === 'var.set' && inputs[0]) actions.push({ type, key: inputs[0], value: inputs[1] || '' }); if (type === 'var.bump' && inputs[0]) actions.push({ type, key: inputs[0], delta: inputs[1] || '0' }); if (type === 'var.del' && inputs[0]) actions.push({ type, key: inputs[0] }); if ((type === 'wi.enableUID' || type === 'wi.disableUID') && inputs[0] && inputs[1]) actions.push({ type, file: inputs[0], uid: inputs[1] }); if (type === 'wi.setContentUID' && inputs[0] && inputs[1]) actions.push({ type, file: inputs[0], uid: inputs[1], content: inputs[2] || '' }); if (type === 'wi.createContent' && inputs[0]) actions.push({ type, file: inputs[0], key: inputs[1] || '', content: inputs[2] || '' }); if (type === 'qr.run' && inputs[1]) actions.push({ type, preset: inputs[0] || '', label: inputs[1] }); if (type === 'custom.st' && inputs[0]) { const cmds = inputs[0].split('\n').map(s => s.trim()).filter(Boolean).map(c => c.startsWith('/') ? c : '/' + c).join(' | '); if (cmds) actions.push({ type, script: cmds }); } }
const jsCode = buildSTscriptFromActions(actions), jsBox = block?.querySelector?.('.lwb-ve-js'); if (jsCode && jsBox) jsBox.value = jsCode; ui.wrap.remove();
});
}
export function openBumpAliasBuilder(block) {
const ui = U.mini(`<div class="lwb-ve-section"><div class="lwb-ve-label">bump数值映射每行一条变量名(可空) | 短语或 /regex/flags | 数值)</div><div id="lwb-bump-list"></div><button type="button" class="lwb-ve-btn" id="lwb-add-bump">+映射</button></div>`, 'bump数值映射设置');
const list = ui.body.querySelector('#lwb-bump-list'), addBtn = ui.body.querySelector('#lwb-add-bump');
const addRow = (scope = '', phrase = '', val = '1') => { const row = U.el('div', 'lwb-ve-row', `<input class="lwb-ve-input" placeholder="变量名(可空=全局)" value="${scope}"/><input class="lwb-ve-input" placeholder="短语 或 /regex(例:/她(很)?开心/i)" value="${phrase}"/><input class="lwb-ve-input" placeholder="数值(整数,可负)" value="${val}"/><button type="button" class="lwb-ve-btn ghost lwb-ve-del">删除</button>`); row.querySelector('.lwb-ve-del').addEventListener('click', () => row.remove()); list.appendChild(row); };
addBtn.addEventListener('click', () => addRow());
try { const store = getBumpAliasStore() || {}; const addFromBucket = (scope, bucket) => { let n = 0; for (const [phrase, val] of Object.entries(bucket || {})) { addRow(scope, phrase, String(val)); n++; } return n; }; let prefilled = 0; if (store._global) prefilled += addFromBucket('', store._global); for (const [scope, bucket] of Object.entries(store || {})) if (scope !== '_global') prefilled += addFromBucket(scope, bucket); if (prefilled === 0) addRow(); } catch { addRow(); }
ui.btnOk.addEventListener('click', async () => { try { const rows = U.qa(list, '.lwb-ve-row'), items = rows.map(r => { const ins = U.qa(r, '.lwb-ve-input').map(i => i.value); return { scope: (ins[0] || '').trim(), phrase: (ins[1] || '').trim(), val: Number(ins[2] || 0) }; }).filter(x => x.phrase), next = {}; for (const it of items) { const bucket = it.scope ? (next[it.scope] ||= {}) : (next._global ||= {}); bucket[it.phrase] = Number.isFinite(it.val) ? it.val : 0; } await setBumpAliasStore(next); U.toast.ok('Bump 映射已保存到角色卡'); ui.wrap.remove(); } catch {} });
}
function tryInjectButtons(root) {
const scope = root.closest?.('#WorldInfo') || document.getElementById('WorldInfo') || root;
scope.querySelectorAll?.('.world_entry .alignitemscenter.flex-container .editor_maximize')?.forEach((maxBtn) => {
const container = maxBtn.parentElement; if (!container || container.querySelector('.lwb-var-editor-button')) return;
const entry = container.closest('.world_entry'), uid = entry?.getAttribute('data-uid') || entry?.dataset?.uid || (window?.jQuery ? window.jQuery(entry).data('uid') : undefined);
const btn = U.el('div', 'right_menu_button interactable lwb-var-editor-button'); btn.title = '条件规则编辑器'; btn.innerHTML = '<i class="fa-solid fa-pen-ruler"></i>';
btn.addEventListener('click', () => openVarEditor(entry || undefined, uid)); container.insertBefore(btn, maxBtn.nextSibling);
});
}
function observeWIEntriesForEditorButton() {
try { LWBVE.obs?.disconnect(); LWBVE.obs = null; } catch {}
const root = document.getElementById('WorldInfo') || document.body;
const cb = (() => { let t = null; return () => { clearTimeout(t); t = setTimeout(() => tryInjectButtons(root), 100); }; })();
const obs = new MutationObserver(() => cb()); try { obs.observe(root, { childList: true, subtree: true }); } catch {} LWBVE.obs = obs;
}
export function initVareventEditor() {
if (initialized) return; initialized = true;
events = createModuleEvents(MODULE_ID);
injectEditorStyles();
installWIHiddenTagStripper();
registerWIEventSystem();
observeWIEntriesForEditorButton();
setTimeout(() => tryInjectButtons(document.body), 600);
if (typeof window !== 'undefined') { window.LWBVE = LWBVE; window.openVarEditor = openVarEditor; window.openActionBuilder = openActionBuilder; window.openBumpAliasBuilder = openBumpAliasBuilder; window.parseVareventEvents = parseVareventEvents; window.getBumpAliasStore = getBumpAliasStore; window.setBumpAliasStore = setBumpAliasStore; window.clearBumpAliasStore = clearBumpAliasStore; }
LWBVE.installed = true;
}
export function cleanupVareventEditor() {
if (!initialized) return;
events?.cleanup(); events = null;
U.qa(document, '.lwb-ve-overlay').forEach(el => el.remove());
U.qa(document, '.lwb-var-editor-button').forEach(el => el.remove());
document.getElementById(EDITOR_STYLES_ID)?.remove();
try { getContext()?.setExtensionPrompt?.(LWB_VAREVENT_PROMPT_KEY, '', 0, 0, false); } catch {}
try { const { eventSource } = getContext() || {}; const orig = eventSource && origEmitMap.get(eventSource); if (orig) { eventSource.emit = orig; origEmitMap.delete(eventSource); } } catch {}
try { LWBVE.obs?.disconnect(); LWBVE.obs = null; } catch {}
if (typeof window !== 'undefined') LWBVE.installed = false;
initialized = false;
}
// 供 variables-core.js 复用的解析工具
export { stripYamlInlineComment, OP_MAP, TOP_OP_RE };
export { MODULE_ID, LWBVE };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,679 @@
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
import { saveSettingsDebounced, chat_metadata } from "../../../../../../script.js";
import { getLocalVariable, setLocalVariable, getGlobalVariable, setGlobalVariable } from "../../../../../variables.js";
import { extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
const CONFIG = {
extensionName: "variables-panel",
extensionFolderPath,
defaultSettings: { enabled: false },
watchInterval: 1500, touchTimeout: 4000, longPressDelay: 700,
};
const EMBEDDED_CSS = `
.vm-container{color:var(--SmartThemeBodyColor);background:var(--SmartThemeBlurTintColor);flex-direction:column;overflow-y:auto;z-index:3000;position:fixed;display:none}
.vm-container:not([style*="display: none"]){display:flex}
@media (min-width: 1000px){.vm-container:not([style*="display: none"]){width:calc((100vw - var(--sheldWidth)) / 2);border-left:1px solid var(--SmartThemeBorderColor);right:0;top:0;height:100vh}}
@media (max-width: 999px){.vm-container:not([style*="display: none"]){max-height:calc(100svh - var(--topBarBlockSize));top:var(--topBarBlockSize);width:100%;height:100vh;left:0}}
.vm-header,.vm-section,.vm-item-content{border-bottom:.5px solid var(--SmartThemeBorderColor)}
.vm-header,.vm-section-header{display:flex;justify-content:space-between;align-items:center}
.vm-title,.vm-item-name{font-weight:bold}
.vm-header{padding:15px}.vm-title{font-size:16px}
.vm-section-header{padding:5px 15px;border-bottom:5px solid var(--SmartThemeBorderColor);font-size:14px;color:var(--SmartThemeEmColor)}
.vm-close,.vm-btn{background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}
.vm-close{font-size:18px;padding:5px}
.vm-btn{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;font-size:12px;padding:2px 4px;color:var(--SmartThemeBodyColor)}
.vm-search-container{padding:10px;border-bottom:1px solid var(--SmartThemeBorderColor)}
.vm-search-input{width:100%;padding:3px 6px}
.vm-clear-all-btn{color:#ff6b6b;border-color:#ff6b6b;opacity:.3}
.vm-list{flex:1;overflow-y:auto;padding:10px}
.vm-item{border:1px solid var(--SmartThemeBorderColor);opacity:.7}
.vm-item.expanded{opacity:1}
.vm-item-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;padding-left:5px}
.vm-item-name{font-size:13px}
.vm-item-controls{background:var(--SmartThemeChatTintColor);display:flex;gap:5px;position:absolute;right:5px;opacity:0;visibility:hidden}
.vm-item-content{border-top:1px solid var(--SmartThemeBorderColor);display:none}
.vm-item.expanded>.vm-item-content{display:block}
.vm-inline-form{background:var(--SmartThemeChatTintColor);border:1px solid var(--SmartThemeBorderColor);border-top:none;padding:10px;margin:0;display:none}
.vm-inline-form.active{display:block;animation:slideDown .2s ease-out}
@keyframes slideDown{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:200px;padding-top:10px;padding-bottom:10px}}
@media (hover:hover){.vm-close:hover,.vm-btn:hover{opacity:.8}.vm-close:hover{color:red}.vm-clear-all-btn:hover{opacity:1}.vm-item:hover>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-list:hover::-webkit-scrollbar-thumb{background:var(--SmartThemeQuoteColor)}.vm-variable-checkbox:hover{background-color:rgba(255,255,255,.1)}}
@media (hover:none){.vm-close:active,.vm-btn:active{opacity:.8}.vm-close:active{color:red}.vm-clear-all-btn:active{opacity:1}.vm-item:active>.vm-item-header .vm-item-controls,.vm-item.touched>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-item.touched>.vm-item-header{background-color:rgba(255,255,255,.05)}.vm-btn:active{background-color:rgba(255,255,255,.1);transform:scale(.95)}.vm-variable-checkbox:active{background-color:rgba(255,255,255,.1)}}
.vm-item:not([data-level]).expanded .vm-item[data-level="1"]{--level-color:hsl(36,100%,50%)}
.vm-item[data-level="1"].expanded .vm-item[data-level="2"]{--level-color:hsl(60,100%,50%)}
.vm-item[data-level="2"].expanded .vm-item[data-level="3"]{--level-color:hsl(120,100%,50%)}
.vm-item[data-level="3"].expanded .vm-item[data-level="4"]{--level-color:hsl(180,100%,50%)}
.vm-item[data-level="4"].expanded .vm-item[data-level="5"]{--level-color:hsl(240,100%,50%)}
.vm-item[data-level="5"].expanded .vm-item[data-level="6"]{--level-color:hsl(280,100%,50%)}
.vm-item[data-level="6"].expanded .vm-item[data-level="7"]{--level-color:hsl(320,100%,50%)}
.vm-item[data-level="7"].expanded .vm-item[data-level="8"]{--level-color:hsl(200,100%,50%)}
.vm-item[data-level="8"].expanded .vm-item[data-level="9"]{--level-color:hsl(160,100%,50%)}
.vm-item[data-level]{border-left:2px solid var(--level-color);margin-left:6px}
.vm-item[data-level]:last-child{border-bottom:2px solid var(--level-color)}
.vm-tree-value,.vm-variable-checkbox span{font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.vm-tree-value{color:inherit;font-size:12px;flex:1;margin:0 10px}
.vm-input,.vm-textarea{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;background-color:var(--SmartThemeChatTintColor);font-size:12px;margin:3px 0}
.vm-textarea{min-height:60px;padding:5px;font-family:monospace;resize:vertical}
.vm-add-form{padding:10px;border-top:1px solid var(--SmartThemeBorderColor);display:none}
.vm-add-form.active{display:block}
.vm-form-row{display:flex;gap:10px;margin-bottom:10px;align-items:center}
.vm-form-label{min-width:30px;font-size:12px;font-weight:bold}
.vm-form-input{flex:1}
.vm-form-buttons{display:flex;gap:5px;justify-content:flex-end}
.vm-list::-webkit-scrollbar{width:6px}
.vm-list::-webkit-scrollbar-track{background:var(--SmartThemeBodyColor)}
.vm-list::-webkit-scrollbar-thumb{background:var(--SmartThemeBorderColor);border-radius:3px}
.vm-empty-message{padding:20px;text-align:center;color:#888}
.vm-item-name-visible{opacity:1}
.vm-item-separator{opacity:.3}
.vm-null-value{opacity:.6}
.mes_btn.mes_variables_panel{opacity:.6}
.mes_btn.mes_variables_panel:hover{opacity:1}
.vm-badges{display:inline-flex;gap:6px;margin-left:6px;align-items:center}
.vm-badge[data-type="ro"]{color:#F9C770}
.vm-badge[data-type="struct"]{color:#48B0C7}
.vm-badge[data-type="cons"]{color:#D95E37}
.vm-badge:hover{opacity:1;filter:saturate(1.2)}
:root{--vm-badge-nudge:0.06em}
.vm-item-name{display:inline-flex;align-items:center}
.vm-badges{display:inline-flex;gap:.35em;margin-left:.35em}
.vm-item-name .vm-badge{display:flex;width:1em;position:relative;top:var(--vm-badge-nudge) !important;opacity:.9}
.vm-item-name .vm-badge i{display:block;font-size:.8em;line-height:1em}
`;
const EMBEDDED_HTML = `
<div id="vm-container" class="vm-container" style="display:none">
<div class="vm-header">
<div class="vm-title">变量面板</div>
<button id="vm-close" class="vm-close"><i class="fa-solid fa-times"></i></button>
</div>
<div class="vm-content">
${['character','global'].map(t=>`
<div class="vm-section" id="${t}-variables-section">
<div class="vm-section-header">
<div class="vm-section-title"><i class="fa-solid ${t==='character'?'fa-user':'fa-globe'}"></i>${t==='character'?' 本地变量':' 全局变量'}</div>
<div class="vm-section-controls">
${[['import','fa-upload','导入变量'],['export','fa-download','导出变量'],['add','fa-plus','添加变量'],['collapse','fa-chevron-down','展开/折叠所有'],['clear-all','fa-trash','清除所有变量']].map(([a,ic,ti])=>`<button class="vm-btn ${a==='clear-all'?'vm-clear-all-btn':''}" data-type="${t}" data-act="${a}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('')}
</div>
</div>
<div class="vm-search-container"><input type="text" class="vm-input vm-search-input" id="${t}-vm-search" placeholder="搜索${t==='character'?'本地':'全局'}变量..."></div>
<div class="vm-list" id="${t}-variables-list"></div>
<div class="vm-add-form" id="${t}-vm-add-form">
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input" id="${t}-vm-name" placeholder="变量名称"></div>
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input" id="${t}-vm-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
<div class="vm-form-buttons">
<button class="vm-btn" data-type="${t}" data-act="save-add"><i class="fa-solid fa-floppy-disk"></i>保存</button>
<button class="vm-btn" data-type="${t}" data-act="cancel-add">取消</button>
</div>
</div>
</div>`).join('')}
</div>
</div>
`;
const VT = {
character: { getter: getLocalVariable, setter: setLocalVariable, storage: ()=> chat_metadata?.variables || (chat_metadata.variables = {}), save: saveMetadataDebounced },
global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced },
};
const LWB_RULES_KEY='LWB_RULES';
const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } };
const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } };
const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined);
const hasAnyRule = (n)=>{
if(!n) return false;
if(n.ro) return true;
if(n.objectPolicy && n.objectPolicy!=='none') return true;
if(n.arrayPolicy && n.arrayPolicy!=='lock') return true;
const c=n.constraints||{};
return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source);
};
const ruleTip = (n)=>{
if(!n) return '';
const lines=[], c=n.constraints||{};
if(n.ro) lines.push('只读:$ro');
if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext可增键',prune:'$prune可删键',free:'$free可增删键'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); }
if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow可增项',shrink:'$shrink可删项',list:'$list可增删项'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); }
if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); }
if('step'in c) lines.push(`步长:$step=${c.step}`);
if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`);
if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`);
return lines.join('\n');
};
const badgesHtml = (n)=>{
if(!hasAnyRule(n)) return '';
const tip=ruleTip(n).replace(/"/g,'&quot;'), out=[];
if(n.ro) out.push(`<span class="vm-badge" data-type="ro" title="${tip}"><i class="fa-solid fa-shield-halved"></i></span>`);
if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(`<span class="vm-badge" data-type="struct" title="${tip}"><i class="fa-solid fa-diagram-project"></i></span>`);
const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(`<span class="vm-badge" data-type="cons" title="${tip}"><i class="fa-solid fa-ruler-vertical"></i></span>`);
return out.length?`<span class="vm-badges">${out.join('')}</span>`:'';
};
const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}};
class VariablesPanel {
constructor(){
this.state={isOpen:false,isEnabled:false,container:null,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''};
this.variableSnapshot=null; this.eventHandlers={}; this.savingInProgress=false; this.containerHtml=EMBEDDED_HTML;
}
async init(){
this.injectUI(); this.bindControlToggle();
const s=this.getSettings(); this.state.isEnabled=s.enabled; this.syncCheckbox();
if(s.enabled) this.enable();
}
injectUI(){
if(!document.getElementById('variables-panel-css')){
const st=document.createElement('style'); st.id='variables-panel-css'; st.textContent=EMBEDDED_CSS; document.head.appendChild(st);
}
}
getSettings(){ extension_settings.LittleWhiteBox ??= {}; return extension_settings.LittleWhiteBox.variablesPanel ??= {...CONFIG.defaultSettings}; }
vt(t){ return VT[t]; }
store(t){ return this.vt(t).storage(); }
enable(){
this.createContainer(); this.bindEvents();
['character','global'].forEach(t=>this.normalizeStore(t));
this.loadVariables(); this.installMessageButtons();
}
disable(){ this.cleanup(); }
cleanup(){
this.stopWatcher(); this.unbindEvents(); this.unbindControlToggle(); this.removeContainer(); this.removeAllMessageButtons();
const tm=this.state.timers; if(tm.watcher) clearInterval(tm.watcher); if(tm.longPress) clearTimeout(tm.longPress);
tm.touch.forEach(x=>clearTimeout(x)); tm.touch.clear();
Object.assign(this.state,{isOpen:false,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''});
this.variableSnapshot=null; this.savingInProgress=false;
}
createContainer(){
if(!this.state.container?.length){
$('body').append(this.containerHtml);
this.state.container=$("#vm-container");
$("#vm-close").off('click').on('click',()=>this.close());
}
}
removeContainer(){ this.state.container?.remove(); this.state.container=null; }
open(){
if(!this.state.isEnabled) return toastr.warning('请先启用变量面板');
this.createContainer(); this.bindEvents(); this.state.isOpen=true; this.state.container.show();
this.state.rulesChecksum = JSON.stringify(getRulesTable()||{});
this.loadVariables(); this.startWatcher();
}
close(){ this.state.isOpen=false; this.stopWatcher(); this.unbindEvents(); this.removeContainer(); }
bindControlToggle(){
const id='xiaobaix_variables_panel_enabled';
const bind=()=>{
const cb=document.getElementById(id); if(!cb) return false;
this.handleCheckboxChange && cb.removeEventListener('change',this.handleCheckboxChange);
this.handleCheckboxChange=e=> this.toggleEnabled(e.target instanceof HTMLInputElement ? !!e.target.checked : false);
cb.addEventListener('change',this.handleCheckboxChange); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; return true;
};
if(!bind()) setTimeout(bind,100);
}
unbindControlToggle(){
const cb=document.getElementById('xiaobaix_variables_panel_enabled');
if(cb && this.handleCheckboxChange) cb.removeEventListener('change',this.handleCheckboxChange);
this.handleCheckboxChange=null;
}
syncCheckbox(){ const cb=document.getElementById('xiaobaix_variables_panel_enabled'); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; }
bindEvents(){
if(!this.state.container?.length) return;
this.unbindEvents();
const ns='.vm';
$(document)
.on(`click${ns}`,'.vm-section [data-act]',e=>this.onHeaderAction(e))
.on(`touchstart${ns}`,'.vm-item>.vm-item-header',e=>this.handleTouch(e))
.on(`click${ns}`,'.vm-item>.vm-item-header',e=>this.handleItemClick(e))
.on(`click${ns}`,'.vm-item-controls [data-act]',e=>this.onItemAction(e))
.on(`click${ns}`,'.vm-inline-form [data-act]',e=>this.onInlineAction(e))
.on(`mousedown${ns} touchstart${ns}`,'[data-act="copy"]',e=>this.bindCopyPress(e));
['character','global'].forEach(t=> $(`#${t}-vm-search`).on('input',e=>{
if(e.currentTarget instanceof HTMLInputElement) this.searchVariables(t,e.currentTarget.value);
else this.searchVariables(t,'');
}));
}
unbindEvents(){ $(document).off('.vm'); ['character','global'].forEach(t=> $(`#${t}-vm-search`).off('input')); }
onHeaderAction(e){
e.preventDefault(); e.stopPropagation();
const b=$(e.currentTarget), act=b.data('act'), t=b.data('type');
({
import:()=>this.importVariables(t),
export:()=>this.exportVariables(t),
add:()=>this.showAddForm(t),
collapse:()=>this.collapseAll(t),
'clear-all':()=>this.clearAllVariables(t),
'save-add':()=>this.saveAddVariable(t),
'cancel-add':()=>this.hideAddForm(t),
}[act]||(()=>{}))();
}
onItemAction(e){
e.preventDefault(); e.stopPropagation();
const btn=$(e.currentTarget), act=btn.data('act'), item=btn.closest('.vm-item'),
t=this.getVariableType(item), path=this.getItemPath(item);
({
edit: ()=>this.editAction(item,'edit',t,path),
'add-child': ()=>this.editAction(item,'addChild',t,path),
delete: ()=>this.handleDelete(item,t,path),
copy: ()=>{}
}[act]||(()=>{}))();
}
onInlineAction(e){ e.preventDefault(); e.stopPropagation(); const act=$(e.currentTarget).data('act'); act==='inline-save'? this.handleInlineSave($(e.currentTarget).closest('.vm-inline-form')) : this.hideInlineForm(); }
bindCopyPress(e){
e.preventDefault(); e.stopPropagation();
const start=Date.now();
this.state.timers.longPress=setTimeout(()=>{ this.handleCopy(e,true); this.state.timers.longPress=null; },CONFIG.longPressDelay);
const release=(re)=>{
if(this.state.timers.longPress){
clearTimeout(this.state.timers.longPress); this.state.timers.longPress=null;
if(re.type!=='mouseleave' && (Date.now()-start)<CONFIG.longPressDelay) this.handleCopy(e,false);
}
$(document).off('mouseup.vm touchend.vm mouseleave.vm',release);
};
$(document).on('mouseup.vm touchend.vm mouseleave.vm',release);
}
stringifyVar(v){ return typeof v==='string'? v : JSON.stringify(v); }
makeSnapshotMap(t){ const s=this.store(t), m={}; for(const[k,v] of Object.entries(s)) m[k]=this.stringifyVar(v); return m; }
startWatcher(){ this.stopWatcher(); this.updateSnapshot(); this.state.timers.watcher=setInterval(()=> this.state.isOpen && this.checkChanges(), CONFIG.watchInterval); }
stopWatcher(){ if(this.state.timers.watcher){ clearInterval(this.state.timers.watcher); this.state.timers.watcher=null; } }
updateSnapshot(){ this.variableSnapshot={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; }
expandChangedKeys(changed){
['character','global'].forEach(t=>{
const set=changed[t]; if(!set?.size) return;
setTimeout(()=>{
const list=$(`#${t}-variables-list .vm-item[data-key]`);
set.forEach(k=> list.filter((_,el)=>$(el).data('key')===k).addClass('expanded'));
},10);
});
}
checkChanges(){
try{
const sum=JSON.stringify(getRulesTable()||{});
if(sum!==this.state.rulesChecksum){
this.state.rulesChecksum=sum;
const keep=this.saveAllExpandedStates();
this.loadVariables(); this.restoreAllExpandedStates(keep);
}
const cur={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') };
const changed={character:new Set(), global:new Set()};
['character','global'].forEach(t=>{
const prev=this.variableSnapshot?.[t]||{}, now=cur[t];
new Set([...Object.keys(prev),...Object.keys(now)]).forEach(k=>{ if(!(k in prev)||!(k in now)||prev[k]!==now[k]) changed[t].add(k);});
});
if(changed.character.size||changed.global.size){
const keep=this.saveAllExpandedStates();
this.variableSnapshot=cur; this.loadVariables(); this.restoreAllExpandedStates(keep); this.expandChangedKeys(changed);
}
}catch{}
}
loadVariables(){
['character','global'].forEach(t=>{
this.renderVariables(t);
$(`#${t}-variables-section [data-act="collapse"] i`).removeClass('fa-chevron-up').addClass('fa-chevron-down');
});
}
renderVariables(t){
const c=$(`#${t}-variables-list`).empty(), s=this.store(t), root=Object.entries(s);
if(!root.length) c.append('<div class="vm-empty-message">暂无变量</div>');
else root.forEach(([k,v])=> c.append(this.createVariableItem(t,k,v,0,[k])));
}
createVariableItem(t,k,v,l=0,fullPath=[]){
const parsed=this.parseValue(v), hasChildren=typeof parsed==='object' && parsed!==null;
const disp = l===0? this.formatTopLevelValue(v) : this.formatValue(v);
const ruleNode=getRuleNodeByPath(fullPath);
return $(`<div class="vm-item ${l>0?'vm-tree-level-var':''}" data-key="${k}" data-type="${t||''}" ${l>0?`data-level="${l}"`:''} data-path="${this.escape(pathKey(fullPath))}">
<div class="vm-item-header">
<div class="vm-item-name vm-item-name-visible">${this.escape(k)}${badgesHtml(ruleNode)}<span class="vm-item-separator">:</span></div>
<div class="vm-tree-value">${disp}</div>
<div class="vm-item-controls">${this.createButtons()}</div>
</div>
${hasChildren?`<div class="vm-item-content">${this.renderChildren(parsed,l+1,fullPath)}</div>`:''}
</div>`);
}
createButtons(){
return [
['edit','fa-edit','编辑'],
['add-child','fa-plus-circle','添加子变量'],
['copy','fa-eye-dropper','复制(长按: 宏,单击: 变量路径)'],
['delete','fa-trash','删除'],
].map(([act,ic,ti])=>`<button class="vm-btn" data-act="${act}" title="${ti}"><i class="fa-solid ${ic}"></i></button>`).join('');
}
createInlineForm(t,target,fs){
const fid=`inline-form-${Date.now()}`;
const inf=$(`
<div class="vm-inline-form" id="${fid}" data-type="${t}">
<div class="vm-form-row"><label class="vm-form-label">名称:</label><input type="text" class="vm-input vm-form-input inline-name" placeholder="变量名称"></div>
<div class="vm-form-row"><label class="vm-form-label">值:</label><textarea class="vm-textarea vm-form-input inline-value" placeholder="变量值 (支持JSON格式)"></textarea></div>
<div class="vm-form-buttons">
<button class="vm-btn" data-act="inline-save"><i class="fa-solid fa-floppy-disk"></i>保存</button>
<button class="vm-btn" data-act="inline-cancel">取消</button>
</div>
</div>`);
this.state.currentInlineForm?.remove();
target.after(inf); this.state.currentInlineForm=inf; this.state.formState={...fs,formId:fid,targetItem:target};
const ta=inf.find('.inline-value'); ta.on('input',()=>this.autoResizeTextarea(ta));
setTimeout(()=>{ inf.addClass('active'); inf.find('.inline-name').focus(); },10);
return inf;
}
renderChildren(obj,level,parentPath){ return Object.entries(obj).map(([k,v])=> this.createVariableItem(null,k,v,level,[...(parentPath||[]),k])[0].outerHTML).join(''); }
handleTouch(e){
if($(e.target).closest('.vm-item-controls').length) return;
e.stopPropagation();
const item=$(e.currentTarget).closest('.vm-item'); $('.vm-item').removeClass('touched'); item.addClass('touched');
this.clearTouchTimer(item);
const t=setTimeout(()=>{ item.removeClass('touched'); this.state.timers.touch.delete(item[0]); },CONFIG.touchTimeout);
this.state.timers.touch.set(item[0],t);
}
clearTouchTimer(i){ const t=this.state.timers.touch.get(i[0]); if(t){ clearTimeout(t); this.state.timers.touch.delete(i[0]); } }
handleItemClick(e){
if($(e.target).closest('.vm-item-controls').length) return;
e.stopPropagation();
$(e.currentTarget).closest('.vm-item').toggleClass('expanded');
}
async writeClipboard(txt){
try{
if(navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(txt);
else { const ta=document.createElement('textarea'); Object.assign(ta.style,{position:'fixed',top:0,left:0,width:'2em',height:'2em',padding:0,border:'none',outline:'none',boxShadow:'none',background:'transparent'}); ta.value=txt; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }
return true;
}catch{ return false; }
}
handleCopy(e,longPress){
const item=$(e.target).closest('.vm-item'), path=this.getItemPath(item), t=this.getVariableType(item), level=parseInt(item.attr('data-level'))||0;
const formatted=this.formatPath(t,path); let cmd='';
if(longPress){
if(t==='character'){
cmd = level===0 ? `{{getvar::${path[0]}}}` : `{{xbgetvar::${formatted}}}`;
}else{
cmd = `{{getglobalvar::${path[0]}}}`;
if(level>0) toastr.info('全局变量宏暂不支持子路径,已复制顶级变量');
}
}else cmd=formatted;
(async()=> (await this.writeClipboard(cmd)) ? toastr.success(`已复制: ${cmd}`) : toastr.error('复制失败'))();
}
editAction(item,action,type,path){
const inf=this.createInlineForm(type,item,{action,path,type});
if(action==='edit'){
const v=this.getValueByPath(type,path);
setTimeout(()=>{
inf.find('.inline-name').val(path[path.length-1]);
const ta=inf.find('.inline-value');
const fill=(val)=> Array.isArray(val)? (val.length===1 ? String(val[0]??'') : JSON.stringify(val,null,2)) : (val&&typeof val==='object'? JSON.stringify(val,null,2) : String(val??''));
ta.val(fill(v)); this.autoResizeTextarea(ta);
},50);
}else if(action==='addChild'){
inf.find('.inline-name').attr('placeholder',`为 "${path.join('.')}" 添加子变量名称`);
inf.find('.inline-value').attr('placeholder','子变量值 (支持JSON格式)');
}
}
handleDelete(_item,t,path){
const n=path[path.length-1];
if(!confirm(`确定要删除 "${n}" 吗?`)) return;
this.withGlobalRefresh(()=> this.deleteByPathSilently(t,path));
toastr.success('变量已删除');
}
refreshAndKeep(t,states){ this.vt(t).save(); this.loadVariables(); this.updateSnapshot(); states && this.restoreExpandedStates(t,states); }
withPreservedExpansion(t,fn){ const s=this.saveExpandedStates(t); fn(); this.refreshAndKeep(t,s); }
withGlobalRefresh(fn){ const s=this.saveAllExpandedStates(); fn(); this.loadVariables(); this.updateSnapshot(); this.restoreAllExpandedStates(s); }
handleInlineSave(form){
if(this.savingInProgress) return; this.savingInProgress=true;
try{
if(!form?.length) return toastr.error('表单未找到');
const rawName=form.find('.inline-name').val();
const rawValue=form.find('.inline-value').val();
const name= typeof rawName==='string'? rawName.trim() : String(rawName ?? '').trim();
const value= typeof rawValue==='string'? rawValue.trim() : String(rawValue ?? '').trim();
const type=form.data('type');
if(!name) return form.find('.inline-name').focus(), toastr.error('请输入变量名称');
const val=this.processValue(value), {action,path}=this.state.formState;
this.withPreservedExpansion(type,()=>{
if(action==='addChild') {
this.setValueByPath(type,[...path,name],val);
} else if(action==='edit'){
const old=path[path.length-1];
if(name!==old){
this.deleteByPathSilently(type,path);
if(path.length===1) {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(type).setter(name,toSave);
} else {
this.setValueByPath(type,[...path.slice(0,-1),name],val);
}
} else {
this.setValueByPath(type,path,val);
}
} else {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(type).setter(name,toSave);
}
});
this.hideInlineForm(); toastr.success('变量已保存');
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
finally{ this.savingInProgress=false; }
}
hideInlineForm(){ if(this.state.currentInlineForm){ this.state.currentInlineForm.removeClass('active'); setTimeout(()=>{ this.state.currentInlineForm?.remove(); this.state.currentInlineForm=null; },200);} this.state.formState={}; }
showAddForm(t){
this.hideInlineForm();
const f=$(`#${t}-vm-add-form`).addClass('active'), ta=$(`#${t}-vm-value`);
$(`#${t}-vm-name`).val('').attr('placeholder','变量名称').focus();
ta.val('').attr('placeholder','变量值 (支持JSON格式)');
if(!ta.data('auto-resize-bound')){ ta.on('input',()=>this.autoResizeTextarea(ta)); ta.data('auto-resize-bound',true); }
}
hideAddForm(t){ $(`#${t}-vm-add-form`).removeClass('active'); $(`#${t}-vm-name, #${t}-vm-value`).val(''); this.state.formState={}; }
saveAddVariable(t){
if(this.savingInProgress) return; this.savingInProgress=true;
try{
const rawN=$(`#${t}-vm-name`).val();
const rawV=$(`#${t}-vm-value`).val();
const n= typeof rawN==='string' ? rawN.trim() : String(rawN ?? '').trim();
const v= typeof rawV==='string' ? rawV.trim() : String(rawV ?? '').trim();
if(!n) return toastr.error('请输入变量名称');
const val=this.processValue(v);
this.withPreservedExpansion(t,()=> {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(t).setter(n,toSave);
});
this.hideAddForm(t); toastr.success('变量已保存');
}catch(e){ toastr.error('JSON格式错误: '+e.message); }
finally{ this.savingInProgress=false; }
}
getValueByPath(t,p){ if(p.length===1) return this.vt(t).getter(p[0]); let v=this.parseValue(this.vt(t).getter(p[0])); p.slice(1).forEach(k=> v=v?.[k]); return v; }
setValueByPath(t,p,v){
if(p.length===1){
const toSave = (typeof v==='object' && v!==null) ? JSON.stringify(v) : v;
this.vt(t).setter(p[0], toSave);
return;
}
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) root={};
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
cur[p[p.length-1]]=v; this.vt(t).setter(p[0], JSON.stringify(root));
}
deleteByPathSilently(t,p){
if(p.length===1){ delete this.store(t)[p[0]]; return; }
let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) return;
let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; });
delete cur[p[p.length-1]]; this.vt(t).setter(p[0], JSON.stringify(root));
}
formatPath(t,path){
if(!Array.isArray(path)||!path.length) return '';
let out=String(path[0]), cur=this.parseValue(this.vt(t).getter(path[0]));
for(let i=1;i<path.length;i++){
const k=String(path[i]), isNum=/^\d+$/.test(k);
if(Array.isArray(cur) && isNum){ out+=`[${Number(k)}]`; cur=cur?.[Number(k)]; }
else { out+=`.`+k; cur=cur?.[k]; }
}
return out;
}
getVariableType(it){ return it.data('type') || (it.closest('.vm-section').attr('id').includes('character')?'character':'global'); }
getItemPath(i){ const p=[]; let c=i; while(c.length&&c.hasClass('vm-item')){ const k=c.data('key'); if(k!==undefined) p.unshift(String(k)); if(!c.attr('data-level')) break; c=c.parent().closest('.vm-item'); } return p; }
parseValue(v){ try{ return typeof v==='string'? JSON.parse(v) : v; }catch{ return v; } }
processValue(v){ if(typeof v!=='string') return v; const s=v.trim(); return (s.startsWith('{')||s.startsWith('['))? JSON.parse(s) : v; }
formatTopLevelValue(v){ const p=this.parseValue(v); if(typeof p==='object'&&p!==null){ const c=Array.isArray(p)? p.length : Object.keys(p).length; return `<span class="vm-object-count">[${c} items]</span>`; } return this.formatValue(p); }
formatValue(v){ if(v==null) return `<span class="vm-null-value">${v}</span>`; const e=this.escape(String(v)); return `<span class="vm-formatted-value">${e.length>50? e.substring(0,50)+'...' : e}</span>`; }
escape(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
autoResizeTextarea(ta){ if(!ta?.length) return; const el=ta[0]; el.style.height='auto'; const sh=el.scrollHeight, max=Math.min(300,window.innerHeight*0.4), fh=Math.max(60,Math.min(max,sh+4)); el.style.height=fh+'px'; el.style.overflowY=sh>max-4?'auto':'hidden'; }
searchVariables(t,q){ const l=q.toLowerCase().trim(); $(`#${t}-variables-list .vm-item`).each(function(){ $(this).toggle(!l || $(this).text().toLowerCase().includes(l)); }); }
collapseAll(t){ const items=$(`#${t}-variables-list .vm-item`), icon=$(`#${t}-variables-section [data-act="collapse"] i`); const any=items.filter('.expanded').length>0; items.toggleClass('expanded',!any); icon.toggleClass('fa-chevron-up',!any).toggleClass('fa-chevron-down',any); }
clearAllVariables(t){
if(!confirm(`确定要清除所有${t==='character'?'角色':'全局'}变量吗?`)) return;
this.withPreservedExpansion(t,()=>{ const s=this.store(t); Object.keys(s).forEach(k=> delete s[k]); });
toastr.success('变量已清除');
}
async importVariables(t){
const inp=document.createElement('input'); inp.type='file'; inp.accept='.json';
inp.onchange=async(e)=>{
try{
const tgt=e.target;
const file = (tgt && 'files' in tgt && tgt.files && tgt.files[0]) ? tgt.files[0] : null;
if(!file) throw new Error('未选择文件');
const txt=await file.text(), v=JSON.parse(txt);
this.withPreservedExpansion(t,()=> {
Object.entries(v).forEach(([k,val])=> {
const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val;
this.vt(t).setter(k,toSave);
});
});
toastr.success(`成功导入 ${Object.keys(v).length} 个变量`);
}catch{ toastr.error('文件格式错误'); }
};
inp.click();
}
exportVariables(t){
const v=this.store(t), b=new Blob([JSON.stringify(v,null,2)],{type:'application/json'}), a=document.createElement('a');
a.href=URL.createObjectURL(b); a.download=`${t}-variables-${new Date().toISOString().split('T')[0]}.json`; a.click();
toastr.success('变量已导出');
}
saveExpandedStates(t){ const s=new Set(); $(`#${t}-variables-list .vm-item.expanded`).each(function(){ const k=$(this).data('key'); if(k!==undefined) s.add(String(k)); }); return s; }
saveAllExpandedStates(){ return { character:this.saveExpandedStates('character'), global:this.saveExpandedStates('global') }; }
restoreExpandedStates(t,s){ if(!s?.size) return; setTimeout(()=>{ $(`#${t}-variables-list .vm-item`).each(function(){ const k=$(this).data('key'); if(k!==undefined && s.has(String(k))) $(this).addClass('expanded'); }); },50); }
restoreAllExpandedStates(st){ Object.entries(st).forEach(([t,s])=> this.restoreExpandedStates(t,s)); }
toggleEnabled(en){
const s=this.getSettings(); s.enabled=this.state.isEnabled=en; saveSettingsDebounced(); this.syncCheckbox();
en ? (this.enable(),this.open()) : this.disable();
}
createPerMessageBtn(messageId){
const btn=document.createElement('div');
btn.className='mes_btn mes_variables_panel';
btn.title='变量面板';
btn.dataset.mid=messageId;
btn.innerHTML='<i class="fa-solid fa-database"></i>';
btn.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); this.open(); });
return btn;
}
addButtonToMessage(messageId){
const msg=$(`#chat .mes[mesid="${messageId}"]`);
if(!msg.length || msg.find('.mes_btn.mes_variables_panel').length) return;
const btn=this.createPerMessageBtn(messageId);
const appendToFlex=(m)=>{ const flex=m.find('.flex-container.flex1.alignitemscenter'); if(flex.length) flex.append(btn); };
if(typeof window['registerButtonToSubContainer']==='function'){
const ok=window['registerButtonToSubContainer'](messageId,btn);
if(!ok) appendToFlex(msg);
} else appendToFlex(msg);
}
addButtonsToAllMessages(){ $('#chat .mes').each((_,el)=>{ const mid=el.getAttribute('mesid'); if(mid) this.addButtonToMessage(mid); }); }
removeAllMessageButtons(){ $('#chat .mes .mes_btn.mes_variables_panel').remove(); }
installMessageButtons(){
const delayedAdd=(id)=> setTimeout(()=>{ if(id!=null) this.addButtonToMessage(id); },120);
const delayedScan=()=> setTimeout(()=> this.addButtonsToAllMessages(),150);
this.removeMessageButtonsListeners();
const idFrom=(d)=> typeof d==='object' ? (d.messageId||d.id) : d;
if (!this.msgEvents) this.msgEvents = createModuleEvents('variablesPanel:messages');
this.msgEvents.onMany([
event_types.USER_MESSAGE_RENDERED,
event_types.CHARACTER_MESSAGE_RENDERED,
event_types.MESSAGE_RECEIVED,
event_types.MESSAGE_UPDATED,
event_types.MESSAGE_SWIPED,
event_types.MESSAGE_EDITED
].filter(Boolean), (d) => delayedAdd(idFrom(d)));
this.msgEvents.on(event_types.MESSAGE_SENT, debounce(() => delayedScan(), 300));
this.msgEvents.on(event_types.CHAT_CHANGED, () => delayedScan());
this.addButtonsToAllMessages();
}
removeMessageButtonsListeners(){
if (this.msgEvents) {
this.msgEvents.cleanup();
}
}
removeMessageButtons(){ this.removeMessageButtonsListeners(); this.removeAllMessageButtons(); }
normalizeStore(t){
const s=this.store(t); let changed=0;
for(const[k,v] of Object.entries(s)){
if(typeof v==='object' && v!==null){
try{ s[k]=JSON.stringify(v); changed++; }catch{}
}
}
if(changed) this.vt(t).save?.();
}
}
let variablesPanelInstance=null;
export async function initVariablesPanel(){
try{
extension_settings.variables ??= { global:{} };
if(variablesPanelInstance) variablesPanelInstance.cleanup();
variablesPanelInstance=new VariablesPanel();
await variablesPanelInstance.init();
return variablesPanelInstance;
}catch(e){
console.error(`[${CONFIG.extensionName}] 加载失败:`,e);
toastr?.error?.('Variables Panel加载失败');
throw e;
}
}
export function getVariablesPanelInstance(){ return variablesPanelInstance; }
export function cleanupVariablesPanel(){ if(variablesPanelInstance){ variablesPanelInstance.removeMessageButtons(); variablesPanelInstance.cleanup(); variablesPanelInstance=null; } }

File diff suppressed because it is too large Load Diff

811
settings.html Normal file
View File

@@ -0,0 +1,811 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&display=swap" rel="stylesheet">
<div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header">
<b>小白X</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div>
<div class="inline-drawer-content">
<div class="littlewhitebox settings-grid">
<div class="settings-menu-vertical">
<div class="menu-tab active" data-target="js-memory" style="border-bottom:1px solid #303030;"><span class="vertical-text">渲染交互</span></div>
<div class="menu-tab" data-target="task" style="border-bottom:1px solid #303030;"><span class="vertical-text">循环任务</span></div>
<div class="menu-tab" data-target="template" style="border-bottom:1px solid #303030;"><span class="vertical-text">数据互动</span></div>
<div class="menu-tab" data-target="wallhaven" style="border-bottom:1px solid #303030;"><span class="vertical-text">辅助工具</span></div>
</div>
<div class="settings-content">
<div class="js-memory settings-section" style="display:block;">
<div class="section-divider" style="margin-bottom:0;margin-top:0;">总开关</div>
<hr class="sysHR" />
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
<input type="checkbox" id="xiaobaix_enabled" />
<label for="xiaobaix_enabled" class="has-tooltip" data-tooltip="渲染被```包裹的html、!DOCTYPE、script的代码块内容为交互式界面
提供STscript(command)异步函数执行酒馆命令:
await STscript('/echo 你好世界!')">启用小白X</label>
</div>
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
<input type="checkbox" id="xiaobaix_render_enabled" />
<label for="xiaobaix_render_enabled" class="has-tooltip" data-tooltip="控制代码块转换为iframe渲染的功能
关闭后将清理所有已渲染的iframe">渲染开关</label>
<label for="xiaobaix_max_rendered" style="margin-left:8px;">渲染楼层</label>
<input id="xiaobaix_max_rendered"
type="number"
class="text_pole dark-number-input"
min="1" max="9999" step="1"
style="width:5rem;margin-left:4px;" />
</div>
<div class="section-divider">渲染模式
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_sandbox" />
<label for="xiaobaix_sandbox">沙盒模式</label>
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_use_blob" />
<label for="xiaobaix_use_blob" class="has-tooltip" data-tooltip="大型html适用">启用Blob渲染</label>
</div>
<div class="flex-container">
<input type="checkbox" id="Wrapperiframe" />
<label for="Wrapperiframe" class="has-tooltip" data-tooltip="按需在 iframe 中注入 Wrapperiframe.js">启用封装函数</label>
</div>
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
<input type="checkbox" id="xiaobaix_audio_enabled" />
<label for="xiaobaix_audio_enabled" style="margin-top:0;">启用音频</label>
</div>
<br>
<div class="section-divider">流式,非基础的渲染
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_template_enabled" />
<label for="xiaobaix_template_enabled" class="has-tooltip" data-tooltip="流式多楼层动态渲染">启用沉浸式模板</label>
</div>
<div id="current_template_settings">
<div class="template-replacer-header">
<div class="template-replacer-title">当前角色模板设置</div>
<div class="template-replacer-controls">
<button id="open_template_editor" class="menu_button menu_button_icon">
<i class="fa-solid fa-pen-to-square"></i>
<small>编辑模板</small>
</button>
</div>
</div>
<div class="template-replacer-status" id="template_character_status">
请选择一个角色
</div>
</div>
<div class="section-divider">功能说明
<hr class="sysHR" />
</div>
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
<div><a href="https://docs.littlewhitebox.qzz.io/" class="download-link" target="_blank">功能文档</a></div>
<button id="xiaobaix_reset_btn" class="menu_button menu_button_icon" type="button" title="把小白X的功能按钮重置回默认功能设定">
<small>默认开关</small>
</button>
<button id="xiaobaix_xposition_btn" class="menu_button menu_button_icon" type="button" title="切换X按钮的位置仅两种">
<small>X按钮:右</small>
</button>
</div>
</div>
<div class="wallhaven settings-section" style="display:none;">
<div class="section-divider">消息日志与拦截
<hr class="sysHR">
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_recorded_enabled" />
<label for="xiaobaix_recorded_enabled" class="has-tooltip" data-tooltip="每条消息添加图标点击可看到发送给时AI的记录">Log记录</label>
<input type="checkbox" id="xiaobaix_preview_enabled" />
<label for="xiaobaix_preview_enabled" class="has-tooltip" data-tooltip="在聊天框显示图标点击可拦截将发送给AI的消息并显示">Log拦截</label>
</div>
<div class="section-divider">写卡AI
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_script_assistant" />
<label for="xiaobaix_script_assistant" class="has-tooltip" data-tooltip="勾选后AI将获取小白X功能和ST脚本语言知识内置 STscript 语法与示例,帮助您创作角色卡">启用写卡助手</label>
</div>
<div class="section-divider">视觉增强
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_immersive_enabled" />
<label for="xiaobaix_immersive_enabled" class="has-tooltip" data-tooltip="重构界面布局与细节优化">沉浸布局显示(边框窄化)</label>
</div>
<div class="flex-container">
<input type="checkbox" id="wallhaven_enabled" />
<label for="wallhaven_enabled" class="has-tooltip" data-tooltip="AI回复时自动提取消息内容转换为标签并获取Wallhaven图片作为聊天背景">自动消息配背景图</label>
</div>
<div id="wallhaven_settings_container" style="display:none;">
<div class="flex-container">
<input type="checkbox" id="wallhaven_bg_mode" />
<label for="wallhaven_bg_mode">背景图模式(纯场景)</label>
</div>
<div class="flex-container">
<label for="wallhaven_category" id="section-font">图片分类:</label>
<select id="wallhaven_category" class="text_pole">
<option value="010">动漫漫画</option>
<option value="111">全部类型</option>
<option value="001">人物写真</option>
<option value="100">综合壁纸</option>
</select>
</div>
<div class="flex-container">
<label for="wallhaven_purity" id="section-font">内容分级:</label>
<select id="wallhaven_purity" class="text_pole">
<option value="100">仅 SFW</option>
<option value="010">仅 Sketchy (轻微)</option>
<option value="110">SFW + Sketchy</option>
<option value="001">仅 NSFW</option>
<option value="011">Sketchy + NSFW</option>
<option value="111">全部内容</option>
</select>
</div>
<div class="flex-container">
<label for="wallhaven_opacity" id="section-font">黑纱透明度: <span id="wallhaven_opacity_value">30%</span></label>
<input type="range" id="wallhaven_opacity" min="0" max="0.8" step="0.1" value="0.3" class="wide50p" />
</div>
<hr class="sysHR">
<div class="flex-container">
<input type="text" id="wallhaven_custom_tag_input" placeholder="输入英文标签,如: beautiful girl" class="text_pole wide50p" />
<button id="wallhaven_add_custom_tag" class="menu_button" type="button" style="width:auto;">+自定义TAG</button>
</div>
<div id="wallhaven_custom_tags_container" class="custom-tags-container">
<div id="wallhaven_custom_tags_list" class="custom-tags-list"></div>
</div>
</div>
<div class="section-divider">Novel 画图
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_novel_draw_enabled" />
<label for="xiaobaix_novel_draw_enabled" class="has-tooltip" data-tooltip="使用 NovelAI 为 AI 回复自动或手动生成配图,需在酒馆设置中配置 NovelAI Key">启用 Novel 画图(暂不可用)</label>
<button id="xiaobaix_novel_draw_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开 Novel 画图详细设置">
<i class="fa-solid fa-palette"></i>
<small>画图设置</small>
</button>
</div>
</div>
<div class="task settings-section" style="display:none;">
<div class="section-divider">循环任务
<hr class="sysHR" />
</div>
<div class="flex-container">
<input type="checkbox" id="scheduled_tasks_enabled" />
<label for="scheduled_tasks_enabled" class="has-tooltip" data-tooltip="自动执行设定好的斜杠命令
输入/xbqte {{任务名称}}可以手动激活任务
导出/入角色卡时, 角色任务会随角色卡一起导出/入">启用循环任务</label>
<div id="toggle_task_bar" class="menu_button menu_button_icon" style="margin: 0px; margin-left: auto;" title="显示/隐藏按钮栏">
<small>按钮栏</small>
</div>
</div>
<hr class="sysHR" />
<div class="flex-container task-tab-bar">
<div class="task-tab active" data-target="global_tasks_block">全局任务<span class="task-count" id="global_task_count"></span></div>
<div class="task-tab" data-target="character_tasks_block">角色任务<span class="task-count" id="character_task_count"></span></div>
<div class="task-tab" data-target="preset_tasks_block">预设任务<span class="task-count" id="preset_task_count"></span></div>
</div>
<div class="flex-container" style="justify-content: flex-end; flex-wrap: nowrap; gap: 0px; margin-top: 10px;">
<div id="add_global_task" class="menu_button menu_button_icon" title="添加全局任务">
<small>+全局</small>
</div>
<div id="add_character_task" class="menu_button menu_button_icon" title="添加角色任务">
<small>+角色</small>
</div>
<div id="add_preset_task" class="menu_button menu_button_icon" title="添加预设任务">
<small>+预设</small>
</div>
<div id="cloud_tasks_button" class="menu_button menu_button_icon" title="从云端获取任务脚本">
<i class="fa-solid fa-cloud-arrow-down"></i>
<small>任务下载</small>
</div>
<div id="import_global_tasks" class="menu_button menu_button_icon" title="导入任务">
<i class="fa-solid fa-download"></i>
<small>导入</small>
</div>
</div>
<hr class="sysHR">
<div class="task-panel-group">
<div id="global_tasks_block" class="padding5 task-panel" data-panel="global_tasks_block">
<small>这些任务在所有角色中的聊天都会执行</small>
<div id="global_tasks_list" class="flex-container task-container flexFlowColumn"></div>
</div>
<div id="character_tasks_block" class="padding5 task-panel" data-panel="character_tasks_block" style="display:none;">
<small>这些任务只在当前角色的聊天中执行</small>
<div id="character_tasks_list" class="flex-container task-container flexFlowColumn"></div>
</div>
<div id="preset_tasks_block" class="padding5 task-panel" data-panel="preset_tasks_block" style="display:none;">
<small>这些任务会在使用<small id="preset_tasks_hint" class="preset-task-hint">未选择</small>预设时执行</small>
<div id="preset_tasks_list" class="flex-container task-container flexFlowColumn"></div>
</div>
</div>
<input type="file" id="import_tasks_file" accept=".json" style="display:none;" />
</div>
<div class="template settings-section" style="display:none;">
<div class="section-divider">四次元壁</div>
<hr class="sysHR" />
<div class="flex-container">
<input type="checkbox" id="xiaobaix_fourth_wall_enabled" />
<label for="xiaobaix_fourth_wall_enabled" class="has-tooltip" data-tooltip="突破第四面墙,与角色进行元对话交流。悬浮按钮位于页面右侧中间。">四次元壁</label>
</div>
<br>
<div class="section-divider">剧情总结</div>
<hr class="sysHR" />
<div class="flex-container">
<input type="checkbox" id="xiaobaix_story_summary_enabled" />
<label for="xiaobaix_story_summary_enabled" class="has-tooltip" data-tooltip="在消息楼层添加总结按钮点击可打开剧情总结面板AI分析生成关键词云、时间线、人物关系、角色弧光">剧情总结面板</label>
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_story_outline_enabled" />
<label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标点击可打开可视化剧情地图编辑器">剧情地图</label>
</div>
<br>
<div class="section-divider">变量控制、世界书执行</div>
<hr class="sysHR" />
<div class="flex-container">
<input type="checkbox" id="xiaobaix_variables_core_enabled" />
<label for="xiaobaix_variables_core_enabled">剧情管理</label>
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_variables_panel_enabled" />
<label for="xiaobaix_variables_panel_enabled">变量面板</label>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.littlewhitebox,
.littlewhitebox * {
font-family: 'ZCOOL KuaiLe', 'ZCOOL XiaoWei', sans-serif !important
}
.littlewhitebox i,
.littlewhitebox .fa,
.littlewhitebox .fa-solid,
.littlewhitebox .fa-regular,
.littlewhitebox .fa-brands {
font-family: 'Font Awesome 6 Free', 'FontAwesome', 'Font Awesome 5 Free', 'Font Awesome 5 Brands', sans-serif !important;
font-weight: 900 !important
}
.littlewhitebox {
display: flex;
gap: 1px
}
.settings-menu-vertical {
display: flex;
flex-direction: column;
flex: .5
}
.settings-content {
flex: 19;
margin-left: 1%;
width: 89%
}
.menu-tab {
flex: none;
padding: 8px 6px;
text-align: center;
cursor: pointer;
color: #696969;
border: none;
transition: color .2s;
font-weight: 500;
writing-mode: vertical-rl;
text-orientation: mixed
}
.menu-tab:hover {
color: #fff
}
.menu-tab.active {
color: #e9e9e9;
border-bottom: none;
border-left: 2px solid #e9e9e9
}
.settings-section {
padding: 1% 2%
}
.template-replacer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px
}
.template-replacer-title {
font-weight: bold;
color: #cacaca
}
.template-replacer-status {
padding: 5px 10px;
background: #2a2a2a;
border-radius: 4px;
margin-bottom: 10px;
font-size: .9em
}
.template-replacer-status.has-settings {
background: #1a4a1a;
color: #90ee90
}
.template-replacer-status.no-character {
background: #4a1a1a;
color: #ffb3b3
}
.dark-number-input {
background-color: rgba(0, 0, 0, .3) !important;
color: var(--SmartThemeText) !important;
border-color: var(--SmartThemeBorderColor) !important;
width: 8vw !important
}
.littlewhitebox input[type="checkbox"] {
width: 1.2rem;
height: 1.2rem;
background: var(--black30a);
border-radius: .25em;
position: relative
}
.littlewhitebox input[type="checkbox"]:checked::after {
content: "";
display: block;
position: absolute;
top: 18%;
left: 18%;
width: 64%;
height: 64%;
background: #999;
border-radius: .13em
}
.littlewhitebox input[type="checkbox"]+label {
color: #888;
transition: color .2s
}
.littlewhitebox input[type="checkbox"]:checked+label {
color: #dbdbdb
}
.section-divider {
font-size: .75em;
color: #8f8f8f;
margin: .5em 0 .2em;
letter-spacing: .05em;
font-weight: 400;
text-align: left;
user-select: none
}
#section-font {
color: #979797
}
.preset-task-hint {
color: #888;
}
.preset-task-hint.no-preset {
color: #a66;
}
.task-tab-bar {
gap: 12px;
flex-wrap: nowrap;
margin-top: 6px;
align-items: center;
justify-content: space-between;
}
.task-tab {
padding: 2px 0;
border-bottom: 2px solid transparent;
cursor: pointer;
color: #9c9c9c;
white-space: nowrap;
flex: 1 1 0;
text-align: center;
}
.task-tab:hover {
color: var(--SmartThemeBodyColor, #e9e9e9);
}
.task-tab.active {
border-bottom-color: var(--SmartThemeAccentColor, #e9e9e9);
color: var(--SmartThemeBodyColor, #e9e9e9);
}
.task-count {
font-size: 0.85em;
opacity: 0.7;
margin-left: 0.25em;
}
.has-tooltip {
position: relative;
cursor: pointer
}
.has-tooltip::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: 120%;
transform: translate(-50%, -50%);
background: #222;
color: #e4e4e4;
padding: 6px 12px;
border-radius: 4px;
white-space: pre-line;
font-size: .9em;
opacity: 0;
pointer-events: none;
transition: opacity .2s;
z-index: 10;
min-width: 180px;
max-width: 300px;
box-shadow: 0 2px 8px rgba(0, 0, 0, .2)
}
.has-tooltip:hover::after {
opacity: 1
}
label {
margin-top: .3em
}
.littlewhitebox-update-text {
color: green;
margin-left: 5px
}
#littlewhitebox-update-extension {
color: #28a745;
cursor: pointer;
margin: 0 0 0 5px;
transition: .2s
}
#littlewhitebox-update-extension:hover {
background-color: rgba(40, 167, 69, .1);
transform: scale(1.1)
}
#littlewhitebox-update-extension.updating {
color: #007bff;
background-color: transparent;
transform: scale(1)
}
</style>
<script>
!function () {
'use strict';
const $ = s => document.querySelector(s), $$ = s => document.querySelectorAll(s), $id = id => document.getElementById(id);
const onReady = fn => { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true }); else fn(); };
function setupUpdateButtonHandlers() {
const btn = $id('littlewhitebox-update-extension'); if (!btn) return;
const b = btn.cloneNode(true); btn.parentNode.replaceChild(b, btn);
b.addEventListener('mouseenter', function () { if (!this.classList.contains('updating')) { this.style.backgroundColor = 'rgba(40,167,69,.1)'; this.style.transform = 'scale(1.1)'; } });
b.addEventListener('mouseleave', function () { if (!this.classList.contains('updating')) { this.style.backgroundColor = 'transparent'; this.style.transform = 'scale(1)'; } });
b.addEventListener('click', async function (e) {
e.preventDefault(); e.stopPropagation();
this.className = 'menu_button fa-solid fa-spinner fa-spin interactable updating';
this.style.color = '#007bff'; this.style.backgroundColor = 'transparent'; this.style.transform = 'scale(1)'; this.title = '正在更新...';
try {
let ok = false;
if (window.updateLittleWhiteBoxExtension) ok = await window.updateLittleWhiteBoxExtension();
else console.error('[小白X] 更新函数不可用');
if (ok) { if (window.removeAllUpdateNotices) window.removeAllUpdateNotices(); }
else {
this.className = 'menu_button fa-solid fa-cloud-arrow-down interactable has-update';
this.classList.remove('updating'); this.style.color = '#28a745'; this.title = '下載並安裝小白x的更新';
}
} catch {
this.className = 'menu_button fa-solid fa-cloud-arrow-down interactable has-update';
this.classList.remove('updating'); this.style.color = '#28a745'; this.title = '下載並安裝小白x的更新';
}
});
}
window.setupUpdateButtonInSettings = setupUpdateButtonHandlers;
function setupXBtnPositionButton() {
const KEY = 'xiaobaix_x_btn_position';
const LABEL = { 'edit-right': 'X按钮:左', 'name-left': 'X按钮:右' };
const readLS = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
const writeLS = (v) => { try { localStorage.setItem(KEY, v); } catch { } };
let cur = window?.extension_settings?.LittleWhiteBox?.xBtnPosition || readLS() || 'name-left';
const write = (v) => {
cur = v;
try {
const es = (window.extension_settings ||= {});
const box = (es.LittleWhiteBox ||= {});
box.xBtnPosition = v;
window.saveSettingsDebounced?.();
} catch { }
writeLS(v);
};
const applyLabelTo = (el) => {
if (!el) return;
const sm = el.querySelector?.('small') || el;
const txt = LABEL[cur] || LABEL['name-left'];
if (sm.textContent !== txt) sm.textContent = txt;
};
document.querySelectorAll('#xiaobaix_xposition_btn').forEach(applyLabelTo);
if (!document.getElementById('xiaobaix_xposition_btn')) {
const mo = new MutationObserver((_, obs) => {
const el = document.getElementById('xiaobaix_xposition_btn');
if (el) { applyLabelTo(el); obs.disconnect(); }
});
try { mo.observe(document.body, { childList: true, subtree: true }); } catch { }
}
const onClick = (ev) => {
const el = ev.target?.closest?.('#xiaobaix_xposition_btn');
if (!el) return;
ev.preventDefault(); ev.stopPropagation();
write(cur === 'edit-right' ? 'name-left' : 'edit-right');
applyLabelTo(el);
};
document.addEventListener('click', onClick, { passive: false });
window?.registerModuleCleanup?.('xbPosBtn', () => {
document.removeEventListener('click', onClick);
});
}
const EXT_ID = 'LittleWhiteBox';
const KEY_TO_CHECKBOX = {
recorded: 'xiaobaix_recorded_enabled',
immersive: 'xiaobaix_immersive_enabled',
preview: 'xiaobaix_preview_enabled',
scriptAssistant: 'xiaobaix_script_assistant',
tasks: 'scheduled_tasks_enabled',
templateEditor: 'xiaobaix_template_enabled',
wallhaven: 'wallhaven_enabled',
fourthWall: 'xiaobaix_fourth_wall_enabled',
variablesPanel: 'xiaobaix_variables_panel_enabled',
variablesCore: 'xiaobaix_variables_core_enabled',
audio: 'xiaobaix_audio_enabled',
storySummary: 'xiaobaix_story_summary_enabled',
storyOutline: 'xiaobaix_story_outline_enabled',
sandboxMode: 'xiaobaix_sandbox',
useBlob: 'xiaobaix_use_blob',
wrapperIframe: 'Wrapperiframe',
renderEnabled: 'xiaobaix_render_enabled',
};
const DEFAULTS_ON = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'audio', 'storySummary', 'storyOutline'];
const DEFAULTS_OFF = ['recorded', 'preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel'];
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'audio', 'storySummary', 'storyOutline'];
function setModuleEnabled(key, enabled) {
try {
if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};
extension_settings[EXT_ID][key].enabled = !!enabled;
} catch (e) { }
const id = KEY_TO_CHECKBOX[key], el = id ? $id(id) : null;
if (el) { el.checked = !!enabled; try { $(el).trigger('change'); } catch (e) { } }
}
function captureStates() {
const out = { modules: {}, sandboxMode: false, useBlob: false, wrapperIframe: false, renderEnabled: true };
try { MODULE_KEYS.forEach(k => { out.modules[k] = !!(extension_settings[EXT_ID][k] && extension_settings[EXT_ID][k].enabled); }); } catch (e) { }
try { out.sandboxMode = !!extension_settings[EXT_ID].sandboxMode; } catch (e) { }
try { out.useBlob = !!extension_settings[EXT_ID].useBlob; } catch (e) { }
try { out.wrapperIframe = !!extension_settings[EXT_ID].wrapperIframe; } catch (e) { }
try { out.renderEnabled = extension_settings[EXT_ID].renderEnabled !== false; } catch (e) { }
return out;
}
function applyStates(st) {
if (!st) return;
try { Object.keys(st.modules || {}).forEach(k => setModuleEnabled(k, !!st.modules[k])); } catch (e) { }
try {
extension_settings[EXT_ID].sandboxMode = !!st.sandboxMode;
const el = $id('xiaobaix_sandbox'); if (el) { el.checked = !!st.sandboxMode; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].useBlob = !!st.useBlob;
const el = $id('xiaobaix_use_blob'); if (el) { el.checked = !!st.useBlob; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].wrapperIframe = !!st.wrapperIframe;
const el = $id('Wrapperiframe'); if (el) { el.checked = !!st.wrapperIframe; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].renderEnabled = st.renderEnabled !== false;
const el = $id('xiaobaix_render_enabled'); if (el) { el.checked = st.renderEnabled !== false; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
} catch (e) { }
try { if (window.saveSettingsDebounced) window.saveSettingsDebounced(); } catch (e) { }
}
function applyResetDefaults() {
DEFAULTS_ON.forEach(k => setModuleEnabled(k, true));
DEFAULTS_OFF.forEach(k => setModuleEnabled(k, false));
try {
extension_settings[EXT_ID].sandboxMode = false; const sb = $id(KEY_TO_CHECKBOX.sandboxMode);
if (sb) { sb.checked = false; try { $(sb).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].useBlob = false; const bl = $id(KEY_TO_CHECKBOX.useBlob);
if (bl) { bl.checked = false; try { $(bl).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].wrapperIframe = true; const wp = $id(KEY_TO_CHECKBOX.wrapperIframe);
if (wp) { wp.checked = true; try { $(wp).trigger('change'); } catch (e) { } }
} catch (e) { }
try {
extension_settings[EXT_ID].renderEnabled = true; const re = $id(KEY_TO_CHECKBOX.renderEnabled);
if (re) { re.checked = true; try { $(re).trigger('change'); } catch (e) { } }
} catch (e) { }
try { if (window.saveSettingsDebounced) window.saveSettingsDebounced(); } catch (e) { }
}
function initTaskTabs() {
const tabs = Array.from(document.querySelectorAll('.task-tab'));
if (!tabs.length) return;
const panels = Array.from(document.querySelectorAll('.task-panel'));
const showPanel = (id) => {
panels.forEach(panel => {
panel.style.display = panel.dataset.panel === id ? '' : 'none';
});
};
document.addEventListener('click', function (evt) {
const btn = evt.target.closest('.task-tab');
if (!btn) return;
evt.preventDefault();
evt.stopPropagation();
if (btn.classList.contains('active')) return;
tabs.forEach(t => t.classList.toggle('active', t === btn));
showPanel(btn.dataset.target);
});
const initial = tabs.find(t => t.classList.contains('active')) || tabs[0];
if (initial) {
showPanel(initial.dataset.target);
}
}
window.XB_captureAndStoreStates = function () { try { extension_settings[EXT_ID].prevModuleStatesV2 = captureStates(); if (window.saveSettingsDebounced) window.saveSettingsDebounced(); } catch (e) { } };
window.XB_applyPrevStates = function () { try { const st = extension_settings[EXT_ID].prevModuleStatesV2; if (st) applyStates(st); } catch (e) { } };
onReady(() => {
setupUpdateButtonHandlers();
setupXBtnPositionButton();
initTaskTabs();
try { $(document).off('click.xbreset', '#xiaobaix_reset_btn').on('click.xbreset', '#xiaobaix_reset_btn', e => { e.preventDefault(); e.stopPropagation(); applyResetDefaults(); }); } catch (e) {
const btn = $id('xiaobaix_reset_btn'); if (btn) { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); applyResetDefaults(); }, { once: false }); }
}
});
}();
</script>
<style>
.cloud-tasks-modal {
max-height: 70vh
}
.cloud-tasks-modal * {
font-family: 'ZCOOL KuaiLe', 'ZCOOL XiaoWei', sans-serif !important
}
.cloud-tasks-modal h3 {
margin-top: 0;
color: var(--SmartThemeBodyColor, #e9e9e9)
}
.cloud-tasks-modal h4 {
color: var(--SmartThemeBodyColor, #cacaca);
margin-bottom: 10px
}
.cloud-tasks-section {
margin-bottom: 15px
}
.cloud-tasks-list {
max-height: 180px;
overflow-y: auto
}
.cloud-task-item {
transition: background-color .2s
}
.cloud-task-item:hover {
background-color: rgba(255, 255, 255, .05)
}
</style>
<div id="task_editor_template" style="display:none;">
<div class="task_editor">
<h3>任务编辑器</h3>
<div class="flex-container flexFlowColumn">
<div class="flex1">
<label for="task_name_edit">任务名称</label>
<input class="task_name_edit text_pole textarea_compact" type="text" placeholder="输入任务名称" />
</div>
<div class="flex1">
<label for="task_commands_edit">脚本命令</label>
<textarea class="task_commands_edit text_pole wide100p textarea_compact" style="height:200px;" placeholder="输入要执行的斜杠命令或js, eg: &#10;&lt;&lt;taskjs&gt;&gt;&#10;count++;&#10;&lt;&lt;/taskjs&gt;&gt;&#10;/echo 已完成脚本!"></textarea>
</div>
<div class="flex-container">
<div class="flex1">
<label for="task_interval_edit">楼层间隔</label>
<input class="task_interval_edit text_pole textarea_compact" type="number" min="0" max="100" />
<small>设为0即只手动激活非自动执行</small>
</div>
<div class="flex1">
<label for="task_floor_type_edit">楼层类型</label>
<select class="task_floor_type_edit text_pole textarea_compact">
<option value="all">全部楼层</option>
<option value="user">用户楼层</option>
<option value="llm">LLM楼层</option>
</select>
<small>消息会以第0层开始计算层数</small>
</div>
</div>
<div class="flex-container">
<div class="flex1">
<label for="task_type_edit">任务类型</label>
<select class="task_type_edit text_pole textarea_compact">
<option value="global" id="section-font">全局任务</option>
<option value="character" id="section-font">角色任务</option>
<option value="preset" id="section-font">预设任务</option>
</select>
<br>
<div class="flex1">
<label class="checkbox flex-container">
<input type="checkbox" class="task_enabled_edit" />
<span>启用任务</span>
</label>
<label class="checkbox flex-container">
<input type="checkbox" class="task_button_activated_edit" />
<span>注册任务按钮到主界面</span>
</label>
</div>
</div>
<div class="flex1">
<label for="task_trigger_timing_edit">触发时机</label>
<select class="task_trigger_timing_edit text_pole textarea_compact">
<option value="after_ai">AI消息后</option>
<option value="before_user">用户消息前</option>
<option value="any_message">任意对话</option>
<option value="initialization">角色卡初始化</option>
<option value="plugin_init">插件初始化</option>
<option value="chat_changed">切换聊天后</option>
<option value="only_this_floor">仅在“间隔楼层”的那个楼层执行一次</option>
</select>
<small>选择任务执行的时机</small>
</div>
</div>
</div>
</div>
</div>
<div id="task_item_template" style="display:none;">
<div class="task-item flex-container flexnowrap">
<span class="drag-handle menu-handle">&#9776;</span>
<div class="task_name flexGrow overflow-hidden"></div>
<div class="flex-container flexnowrap">
<label class="checkbox flex-container">
<input type="checkbox" class="disable_task" />
<span class="task-toggle-on fa-solid fa-toggle-on" title="禁用任务"></span>
<span class="task-toggle-off fa-solid fa-toggle-off" title="启用任务"></span>
</label>
<div class="edit_task menu_button" title="编辑任务"><i class="fa-solid fa-pencil"></i></div>
<div class="export_task menu_button" title="导出任务"><i class="fa-solid fa-upload"></i></div>
<div class="delete_task menu_button" title="删除任务"><i class="fa-solid fa-trash"></i></div>
</div>
</div>
</div>
<div id="task_preview_template" style="display:none;">
<div class="task-preview">
<strong class="task-preview-name"></strong> <span class="task-preview-interval"></span>
<div class="task-commands task-preview-commands"></div>
</div>
</div>
<div id="cloud_tasks_modal_template" style="display:none;">
<div class="cloud-tasks-modal">
<h3>任务下载</h3>
<div class="cloud-tasks-loading" style="text-align:center;padding:20px;">
<i class="fa-solid fa-spinner fa-spin"></i> 正在加载云端任务...
</div>
<div class="cloud-tasks-content" style="display:none;">
<div class="cloud-tasks-section">
<h4>全局任务</h4>
<div class="cloud-tasks-list cloud-global-tasks"></div>
</div>
<hr style="margin:15px 0;" />
<div class="cloud-tasks-section">
<h4>角色任务</h4>
<div class="cloud-tasks-list cloud-character-tasks"></div>
</div>
</div>
<div class="cloud-tasks-error" style="display:none;color:#ff6b6b;text-align:center;padding:20px;"></div>
<small>云端任务由贡献者提供并经过基础审核。由于脚本具有较高权限,使用前请查看源码并检查安全性,确认适配您的场景。</small>
</div>
</div>
<div id="cloud_task_item_template" style="display:none;">
<div class="cloud-task-item" style="border:1px solid var(--SmartThemeBorderColor);padding:10px;margin:8px 0;border-radius:4px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<strong class="cloud-task-name"></strong>
<button class="cloud-task-download menu_button menu_button_icon" title="下载并导入此任务">
<small>导入</small>
</button>
</div>
<div class="cloud-task-intro" style="color:#888;font-size:.9em;text-align:left;"></div>
</div>
</div>

471
style.css Normal file
View File

@@ -0,0 +1,471 @@
/* ==================== 基础工具样式 ==================== */
pre:has(+ .xiaobaix-iframe) {
display: none;
}
/* ==================== 循环任务样式 ==================== */
.task-container {
margin-top: 10px;
margin-bottom: 10px;
}
.task-container:empty::after {
content: "No tasks found";
font-size: 0.95em;
opacity: 0.7;
display: block;
text-align: center;
}
.scheduled-tasks-embedded-warning {
padding: 15px;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 8px;
margin: 10px 0;
}
.warning-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px;
background: rgba(255, 193, 7, 0.1);
border-left: 3px solid #ffc107;
border-radius: 4px;
}
.task-item {
align-items: center;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 0 5px;
margin-top: 1px;
margin-bottom: 1px;
}
.task-item:has(.disable_task:checked) .task_name {
text-decoration: line-through;
filter: grayscale(0.5);
}
.task_name {
font-weight: normal;
color: var(--SmartThemeEmColor);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drag-handle {
cursor: grab;
color: var(--SmartThemeQuoteColor);
margin-right: 8px;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
.checkbox {
align-items: center;
}
.task_editor {
width: 100%;
}
.task_editor .flex-container {
gap: 10px;
}
.task_editor textarea {
font-family: 'Courier New', monospace;
}
input.disable_task {
display: none !important;
}
.task-toggle-off {
cursor: pointer;
opacity: 0.5;
filter: grayscale(0.5);
transition: opacity 0.2s ease-in-out;
}
.task-toggle-off:hover {
opacity: 1;
filter: none;
}
.task-toggle-on {
cursor: pointer;
}
.disable_task:checked~.task-toggle-off {
display: block;
}
.disable_task:checked~.task-toggle-on {
display: none;
}
.disable_task:not(:checked)~.task-toggle-off {
display: none;
}
.disable_task:not(:checked)~.task-toggle-on {
display: block;
}
/* ==================== 沉浸式显示模式样式 ==================== */
body.immersive-mode #chat {
padding: 0 !important;
border: 0px !important;
overflow-y: auto;
margin: 0 0px 0px 4px !important;
scrollbar-width: thin;
scrollbar-gutter: auto;
}
.xiaobaix-top-group {
margin-top: 1em !important;
}
@media screen and (min-width: 1001px) {
body.immersive-mode #chat {
scrollbar-width: none;
-ms-overflow-style: none;
/* IE and Edge */
}
body.immersive-mode #chat::-webkit-scrollbar {
display: none;
}
}
body.immersive-mode .mesAvatarWrapper {
margin-top: 1em;
padding-bottom: 0px;
}
body.immersive-mode .swipe_left,
body.immersive-mode .swipeRightBlock {
display: none !important;
}
body.immersive-mode .mes {
margin: 2% 0 0% 0 !important;
}
body.immersive-mode .ch_name {
padding-bottom: 5px;
border-bottom: 0.5px dashed color-mix(in srgb, var(--SmartThemeEmColor) 30%, transparent);
}
body.immersive-mode .mes_block {
padding-left: 0 !important;
margin: 0 0 5px 0 !important;
}
body.immersive-mode .mes_text {
padding: 0px !important;
max-width: 100%;
width: 100%;
margin-top: 5px;
}
body.immersive-mode .mes {
width: 99%;
margin: 0 0.5%;
padding: 0px !important;
}
body.immersive-mode .mes_buttons,
body.immersive-mode .mes_edit_buttons {
position: absolute !important;
top: 0 !important;
right: 0 !important;
}
body.immersive-mode .mes_buttons {
height: 20px;
overflow-x: clip;
}
body.immersive-mode .swipes-counter {
padding-left: 0px;
margin-bottom: 0 !important;
}
body.immersive-mode .flex-container.flex1.alignitemscenter {
min-height: 32px;
}
.immersive-navigation {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 5px;
opacity: 0.7;
}
.immersive-nav-btn {
color: var(--SmartThemeBodyColor);
cursor: pointer;
transition: all 0.2s ease;
background: none;
border: none;
font-size: 12px;
}
.immersive-nav-btn:hover:not(:disabled) {
background-color: rgba(var(--SmartThemeBodyColor), 0.2);
transform: scale(1.1);
}
.immersive-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ==================== 模板编辑器样式 ==================== */
.xiaobai_template_editor {
max-height: 80vh;
overflow-y: auto;
padding: 20px;
border-radius: 8px;
}
.template-replacer-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.template-replacer-title {
font-weight: bold;
color: var(--SmartThemeEmColor, #007bff);
}
.template-replacer-controls {
display: flex;
align-items: center;
gap: 15px;
}
.template-replacer-status {
font-size: 12px;
color: var(--SmartThemeQuoteColor, #888);
font-style: italic;
}
.template-replacer-status.has-settings {
color: var(--SmartThemeEmColor, #007bff);
}
.template-replacer-status.no-character {
color: var(--SmartThemeCheckboxBgColor, #666);
}
/* ==================== 消息预览插件样式 ==================== */
#message_preview_btn {
width: var(--bottomFormBlockSize);
height: var(--bottomFormBlockSize);
margin: 0;
border: none;
cursor: pointer;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 300ms;
color: var(--SmartThemeBodyColor);
font-size: var(--bottomFormIconSize);
}
#message_preview_btn:hover {
opacity: 1;
filter: brightness(1.2);
}
.message-preview-content-box {
font-family: 'Courier New', 'Monaco', 'Menlo', monospace;
white-space: pre-wrap;
max-height: 82vh;
overflow-y: auto;
padding: 15px;
background: #000000 !important;
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 5px;
color: #ffffff !important;
font-size: 12px;
line-height: 1.4;
text-align: left;
padding-bottom: 80px;
}
.mes_history_preview {
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
}
.mes_history_preview:hover {
opacity: 1;
}
/* ==================== 设置菜单和标签样式 ==================== */
.menu-tab {
flex: 1;
padding: 2px 8px;
text-align: center;
cursor: pointer;
color: #ccc;
border: none;
transition: color 0.2s ease;
font-weight: 500;
}
.menu-tab:hover {
color: #fff;
}
.menu-tab.active {
color: #007acc;
border-bottom: 2px solid #007acc;
}
.settings-section {
padding: 10px 0;
}
/* ==================== Wallhaven自定义标签样式 ==================== */
.custom-tags-container {
margin-top: 10px;
}
.custom-tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
min-height: 20px;
padding: 8px;
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #444;
}
.custom-tag-item {
display: flex;
align-items: center;
background: #007acc;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
gap: 6px;
}
.custom-tag-text {
font-weight: 500;
}
.custom-tag-remove {
cursor: pointer;
color: rgba(255, 255, 255, 0.8);
font-weight: bold;
transition: color 0.2s ease;
}
.custom-tag-remove:hover {
color: #ff6b6b;
}
.custom-tags-empty {
color: #888;
font-style: italic;
font-size: 12px;
text-align: center;
padding: 8px;
}
.task_editor .menu_button{
white-space: nowrap;
}
.message-preview-content-box:hover::-webkit-scrollbar-thumb,
.xiaobai_template_editor:hover::-webkit-scrollbar-thumb {
background: var(--SmartThemeAccent);
}
/* ==================== 滚动条样式 ==================== */
.message-preview-content-box::-webkit-scrollbar,
.xiaobai_template_editor::-webkit-scrollbar {
width: 5px;
}
.message-preview-content-box::-webkit-scrollbar-track,
.xiaobai_template_editor::-webkit-scrollbar-track {
background: var(--SmartThemeBlurTintColor);
border-radius: 3px;
}
.message-preview-content-box::-webkit-scrollbar-thumb,
.xiaobai_template_editor::-webkit-scrollbar-thumb {
background: var(--SmartThemeBorderColor);
border-radius: 3px;
}
/* ==================== Story Outline PromptManager 编辑表单 ==================== */
/* 当编辑 lwb_story_outline 条目时,隐藏名称输入框和内容编辑区 */
.completion_prompt_manager_popup_entry_form:has([data-pm-prompt="lwb_story_outline"]) #completion_prompt_manager_popup_entry_form_name {
pointer-events: none;
user-select: none;
}
.1completion_prompt_manager_popup_entry_form:has([data-pm-prompt="lwb_story_outline"]) #completion_prompt_manager_popup_entry_form_prompt {
display: none !important;
}
/* 显示"内容来自外部"的提示 */
.1completion_prompt_manager_popup_entry_form:has([data-pm-prompt="lwb_story_outline"]) .completion_prompt_manager_popup_entry_form_control:has(#completion_prompt_manager_popup_entry_form_prompt)::after {
content: "此提示词的内容来自「LittleWhiteBox」请在小白板中修改哦";
display: block;
padding: 12px;
margin-top: 8px;
border: 1px solid var(--SmartThemeBorderColor);
color: var(--SmartThemeEmColor);
text-align: center;
}
/* 隐藏 lwb_story_outline 条目的 Remove 按钮(保留占位) */
.completion_prompt_manager_prompt[data-pm-identifier="lwb_story_outline"] .prompt-manager-detach-action {
visibility: hidden !important;
}
.completion_prompt_manager_prompt[data-pm-identifier="lwb_story_outline"] .fa-fw.fa-solid.fa-asterisk {
visibility: hidden !important;
position: relative;
}
.completion_prompt_manager_prompt[data-pm-identifier="lwb_story_outline"] .fa-fw.fa-solid.fa-asterisk::after {
content: "\f00d";
/* fa-xmark 的 unicode */
font-family: "Font Awesome 6 Free";
visibility: visible;
position: absolute;
left: 0;
font-size: 1.2em;
}
#completion_prompt_manager_footer_append_prompt option[value="lwb_story_outline"] {
display: none;
}