啊
This commit is contained in:
74
README.md
Normal file
74
README.md
Normal 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`
|
||||
1546
bridges/call-generate-service.js
Normal file
1546
bridges/call-generate-service.js
Normal file
File diff suppressed because it is too large
Load Diff
899
bridges/worldbook-bridge.js
Normal file
899
bridges/worldbook-bridge.js
Normal 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
105
bridges/wrapper-iframe.js
Normal 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
7
core/constants.js
Normal 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
322
core/debug-core.js
Normal 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
241
core/event-manager.js
Normal 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
30
core/slash-command.js
Normal 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
384
core/variable-path.js
Normal 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
73
docs/COPYRIGHT
Normal 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
33
docs/LICENSE.md
Normal 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
95
docs/NOTICE
Normal 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
1718
docs/script-docs.md
Normal file
File diff suppressed because it is too large
Load Diff
766
index.js
Normal file
766
index.js
Normal 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
11
manifest.json
Normal 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
257
modules/button-collapse.js
Normal 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
268
modules/control-audio.js
Normal 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);
|
||||
}
|
||||
}
|
||||
765
modules/debug-panel/debug-panel.html
Normal file
765
modules/debug-panel/debug-panel.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 日志渲染
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
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>
|
||||
743
modules/debug-panel/debug-panel.js
Normal file
743
modules/debug-panel/debug-panel.js
Normal 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;
|
||||
}
|
||||
1115
modules/fourth-wall/fourth-wall.html
Normal file
1115
modules/fourth-wall/fourth-wall.html
Normal file
File diff suppressed because it is too large
Load Diff
1238
modules/fourth-wall/fourth-wall.js
Normal file
1238
modules/fourth-wall/fourth-wall.js
Normal file
File diff suppressed because it is too large
Load Diff
784
modules/iframe-renderer.js
Normal file
784
modules/iframe-renderer.js
Normal 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
473
modules/immersive-mode.js
Normal 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​/​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}​/​${message.swipes.length}`);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
$swipesCounter.html('1​/​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
650
modules/message-preview.js
Normal 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;"><$1></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 };
|
||||
1136
modules/novel-draw/novel-draw.html
Normal file
1136
modules/novel-draw/novel-draw.html
Normal file
File diff suppressed because it is too large
Load Diff
700
modules/novel-draw/novel-draw.js
Normal file
700
modules/novel-draw/novel-draw.js
Normal 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;
|
||||
}
|
||||
75
modules/scheduled-tasks/embedded-tasks.html
Normal file
75
modules/scheduled-tasks/embedded-tasks.html
Normal 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>
|
||||
75
modules/scheduled-tasks/scheduled-tasks.html
Normal file
75
modules/scheduled-tasks/scheduled-tasks.html
Normal 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>
|
||||
2233
modules/scheduled-tasks/scheduled-tasks.js
Normal file
2233
modules/scheduled-tasks/scheduled-tasks.js
Normal file
File diff suppressed because it is too large
Load Diff
104
modules/script-assistant.js
Normal file
104
modules/script-assistant.js
Normal 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 };
|
||||
604
modules/story-outline/story-outline-prompt.js
Normal file
604
modules/story-outline/story-outline-prompt.js
Normal 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. 上一地点结算:给出 deviation(cot_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;';
|
||||
1776
modules/story-outline/story-outline.html
Normal file
1776
modules/story-outline/story-outline.html
Normal file
File diff suppressed because it is too large
Load Diff
1202
modules/story-outline/story-outline.js
Normal file
1202
modules/story-outline/story-outline.js
Normal file
File diff suppressed because it is too large
Load Diff
1313
modules/story-summary/story-summary.html
Normal file
1313
modules/story-summary/story-summary.html
Normal file
File diff suppressed because it is too large
Load Diff
1112
modules/story-summary/story-summary.js
Normal file
1112
modules/story-summary/story-summary.js
Normal file
File diff suppressed because it is too large
Load Diff
1344
modules/streaming-generation.js
Normal file
1344
modules/streaming-generation.js
Normal file
File diff suppressed because it is too large
Load Diff
62
modules/template-editor/template-editor.html
Normal file
62
modules/template-editor/template-editor.html
Normal 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>
|
||||
1448
modules/template-editor/template-editor.js
Normal file
1448
modules/template-editor/template-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
1009
modules/variables/var-commands.js
Normal file
1009
modules/variables/var-commands.js
Normal file
File diff suppressed because it is too large
Load Diff
686
modules/variables/varevent-editor.js
Normal file
686
modules/variables/varevent-editor.js
Normal 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="例如:<Info>……</Info>"></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 };
|
||||
2385
modules/variables/variables-core.js
Normal file
2385
modules/variables/variables-core.js
Normal file
File diff suppressed because it is too large
Load Diff
679
modules/variables/variables-panel.js
Normal file
679
modules/variables/variables-panel.js
Normal 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,'"'), 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; } }
|
||||
2182
modules/wallhaven-background.js
Normal file
2182
modules/wallhaven-background.js
Normal file
File diff suppressed because it is too large
Load Diff
811
settings.html
Normal file
811
settings.html
Normal 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: <<taskjs>> count++; <</taskjs>> /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">☰</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
471
style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user