/** * 世界书编辑器 - 最终稳定版 */ import { world_names, loadWorldInfo, saveWorldInfo } from "/scripts/world-info.js"; import { eventSource, event_types } from '/script.js'; import { showHtmlModal } from '/scripts/extensions/third-party/ST-Amily2-Chat-Optimisation/ui/page-window.js'; const { SillyTavern, TavernHelper } = window; class WorldEditor { constructor() { this.currentWorldBook = null; this.entries = []; this.selectedEntries = new Set(); this.filteredEntries = []; this.isLoading = false; this.currentEditingEntry = null; this.sortState = { key: 'order', asc: true }; this.init(); } init() { if (!this.initializeComponents()) { console.error('[世界书编辑器] 组件初始化失败,5秒后重试...'); setTimeout(() => this.init(), 5000); return; } this.bindEvents(); this.loadAvailableWorldBooks(); this.bindExternalEvents(); // 绑定外部事件监听 } initializeComponents() { const ids = [ 'world-editor-world-select', 'world-editor-refresh-btn', 'world-editor-create-entry-btn', 'world-editor-search-box', 'world-editor-search-btn', 'world-editor-entry-count', 'world-editor-select-all', 'world-editor-selected-count', 'world-editor-batch-actions', 'world-editor-entries-container', 'world-editor-enable-selected-btn', 'world-editor-disable-selected-btn', 'world-editor-set-blue-btn', 'world-editor-set-green-btn', 'world-editor-delete-selected-btn', 'world-editor-set-disable-recursion-btn', 'world-editor-set-prevent-recursion-btn' ]; this.elements = {}; let missing = false; for (const id of ids) { const camelCaseId = id.replace(/-(\w)/g, (_, c) => c.toUpperCase()); this.elements[camelCaseId] = document.getElementById(id); if (!this.elements[camelCaseId] && id.endsWith('container')) { // Only container is critical console.error(`[世界书编辑器] 关键元素缺失: ${id}`); missing = true; } } return !missing; } bindEvents() { this.elements.worldEditorWorldSelect.addEventListener('change', (e) => this.loadWorldBookEntries(e.target.value)); this.elements.worldEditorRefreshBtn.addEventListener('click', () => this.loadAvailableWorldBooks()); document.querySelector('#world-editor-container .world-editor-entries-header').addEventListener('click', (e) => { if (e.target.dataset.sort) { this.sortEntries(e.target.dataset.sort); } }); this.elements.worldEditorCreateEntryBtn.addEventListener('click', () => this.openCreateModal()); this.elements.worldEditorSearchBox.addEventListener('input', () => this.filterEntries()); this.elements.worldEditorSearchBtn.addEventListener('click', () => this.filterEntries()); this.elements.worldEditorSelectAll.addEventListener('change', (e) => this.toggleSelectAll(e.target.checked)); this.elements.worldEditorEnableSelectedBtn.addEventListener('click', () => this.batchUpdateEntries({ enabled: true })); this.elements.worldEditorDisableSelectedBtn.addEventListener('click', () => this.batchUpdateEntries({ enabled: false })); this.elements.worldEditorSetBlueBtn.addEventListener('click', () => this.batchUpdateEntries({ type: 'constant' })); this.elements.worldEditorSetGreenBtn.addEventListener('click', () => this.batchUpdateEntries({ type: 'selective' })); this.elements.worldEditorDeleteSelectedBtn.addEventListener('click', () => this.batchDeleteEntries()); this.elements.worldEditorSetDisableRecursionBtn.addEventListener('click', () => this.toggleBatchRecursion('exclude_recursion', '不可递归')); this.elements.worldEditorSetPreventRecursionBtn.addEventListener('click', () => this.toggleBatchRecursion('prevent_recursion', '防止递归')); } async loadAvailableWorldBooks() { this.setLoading(true); try { const books = await this.getAllWorldBooks(); const select = this.elements.worldEditorWorldSelect; select.innerHTML = ''; books.forEach(book => { const option = document.createElement('option'); option.value = book.name; option.textContent = book.name; select.appendChild(option); }); await this.selectCurrentCharacterWorldBook(); } catch (error) { this.showError('加载世界书列表失败: ' + error.message); } finally { this.setLoading(false); } } async getAllWorldBooks() { if (TavernHelper?.getLorebooks) { const books = await TavernHelper.getLorebooks(); if (Array.isArray(books) && books.length > 0) return books.map(name => ({ name })); } return (world_names || []).map(name => ({ name })); } async selectCurrentCharacterWorldBook() { if (TavernHelper?.getCurrentCharPrimaryLorebook) { const primaryBook = await TavernHelper.getCurrentCharPrimaryLorebook(); if (primaryBook) { this.elements.worldEditorWorldSelect.value = primaryBook; await this.loadWorldBookEntries(primaryBook); } } } async loadWorldBookEntries(worldBookName) { if (!worldBookName) { this.entries = []; this.renderEntries(); return; } this.setLoading(true); this.currentWorldBook = worldBookName; try { let rawEntries = await TavernHelper?.getLorebookEntries?.(worldBookName); if (!rawEntries || rawEntries.length === 0) { const bookData = await loadWorldInfo(worldBookName); if (bookData?.entries) { rawEntries = Object.entries(bookData.entries).map(([uid, entry]) => ({ uid: parseInt(uid), enabled: !entry.disable, type: entry.constant ? 'constant' : 'selective', keys: entry.key || [], content: entry.content || '', position: this.convertPositionFromNative(entry.position), depth: entry.depth, order: entry.order, comment: entry.comment || '' })); } } this.entries = (rawEntries || []).map(e => ({ uid: e.uid, enabled: e.enabled, type: e.type || (e.constant ? 'constant' : 'selective'), keys: e.keys || [], content: e.content || '', position: e.position || 'before_character_definition', depth: (e.position?.startsWith('at_depth')) ? e.depth : null, order: e.order || 100, comment: e.comment || '', exclude_recursion: e.exclude_recursion, prevent_recursion: e.prevent_recursion })); this.filteredEntries = [...this.entries]; this.renderEntries(); this.updateEntryCount(); } catch (error) { this.showError(`加载条目失败: ${error.message}`); this.entries = []; this.renderEntries(); } finally { this.setLoading(false); } } convertPositionFromNative(pos) { const map = { 0: 'before_character_definition', 1: 'after_character_definition', 2: 'before_author_note', 3: 'after_author_note', 4: 'at_depth' }; return map[pos] || 'at_depth'; } renderEntries() { const container = this.elements.worldEditorEntriesContainer; const header = container.querySelector('.world-editor-entries-header'); // Clear only the entry rows, not the header while (container.firstChild && container.firstChild !== header) { container.removeChild(container.firstChild); } while (header && header.nextSibling) { container.removeChild(header.nextSibling); } this.sortFilteredEntries(); if (this.filteredEntries.length === 0) { const emptyState = document.createElement('div'); emptyState.className = 'world-editor-empty-state'; emptyState.innerHTML = '

没有条目

'; container.appendChild(emptyState); return; } const fragment = document.createDocumentFragment(); this.filteredEntries.forEach(e => { const rowHTML = this.renderEntryRow(e).trim(); const tempDiv = document.createElement('div'); tempDiv.innerHTML = rowHTML; const rowElement = tempDiv.firstChild; // Safely set the content to prevent HTML rendering const contentCell = rowElement.querySelector('.world-editor-entry-content'); if (contentCell) { contentCell.textContent = e.content || ''; } fragment.appendChild(rowElement); }); container.appendChild(fragment); this.bindEntryEvents(); } renderEntryRow(entry) { const positionOptions = { 'before_character_definition': '角色前', 'after_character_definition': '角色后', 'before_author_note': '注释前', 'after_author_note': '注释后', 'at_depth': '@D深度', 'at_depth_as_system': '@D深度' }; const positionSelect = ``; return `
${entry.type === 'constant' ? '🔵' : '🟢'}
${entry.content || ''}
${positionSelect}
`; } bindEntryEvents() { this.elements.worldEditorEntriesContainer.querySelectorAll('.world-editor-entry-row').forEach(row => { const uid = parseInt(row.dataset.uid); // Checkbox const checkbox = row.querySelector('.world-editor-entry-checkbox'); checkbox.addEventListener('change', (e) => { if (e.target.checked) this.selectedEntries.add(uid); else this.selectedEntries.delete(uid); row.classList.toggle('selected', e.target.checked); this.updateSelectionUI(); }); // Content click to open modal (for longer edits) row.querySelector('[data-action="open-editor"]').addEventListener('click', () => this.openEditModal(uid)); // Inline toggles (enabled, type) row.querySelectorAll('.inline-toggle').forEach(toggle => { toggle.addEventListener('click', (e) => { e.stopPropagation(); const field = toggle.dataset.field; const entry = this.entries.find(e => e.uid === uid); let newValue; if (field === 'enabled') newValue = !entry.enabled; if (field === 'type') newValue = entry.type === 'constant' ? 'selective' : 'constant'; this.updateSingleEntry(uid, { [field]: newValue }); }); }); // Inline edits (inputs, selects) row.querySelectorAll('.inline-edit').forEach(input => { input.addEventListener('change', (e) => { e.stopPropagation(); const field = input.dataset.field; let value = input.value; if (input.type === 'number') value = parseInt(value, 10); // The 'keys' field is no longer inline editable, so that specific logic can be removed. const updates = { [field]: value }; // If position changes, re-evaluate depth disable state if (field === 'position') { const depthInput = row.querySelector('[data-field="depth"]'); if (depthInput) depthInput.disabled = !value.startsWith('at_depth'); } this.updateSingleEntry(uid, updates); }); input.addEventListener('click', e => e.stopPropagation()); // Prevent row selection when clicking input }); }); } async updateSingleEntry(uid, updates) { const entry = this.entries.find(e => e.uid === uid); if (!entry) return; // Optimistic UI update Object.assign(entry, updates); this.renderEntries(); // Re-render to reflect changes immediately try { await TavernHelper.setLorebookEntries(this.currentWorldBook, [{ ...entry, ...updates }]); } catch (error) { this.showError(`更新失败: ${error.message}`); // Revert on failure this.loadWorldBookEntries(this.currentWorldBook); } } async batchUpdateEntries(updates, confirmation = null) { if (this.selectedEntries.size === 0) return; if (confirmation && !confirm(confirmation)) return; const entries = this.entries.filter(e => this.selectedEntries.has(e.uid)).map(e => ({ ...e, ...updates })); await TavernHelper.setLorebookEntries(this.currentWorldBook, entries); this.loadWorldBookEntries(this.currentWorldBook); // Refresh if (window.toastr) window.toastr.success('批量更新成功!'); } toggleBatchRecursion(field, fieldName) { if (this.selectedEntries.size === 0) return; const selected = this.entries.filter(e => this.selectedEntries.has(e.uid)); const enabledCount = selected.filter(e => e[field]).length; const shouldEnable = enabledCount <= selected.length / 2; const action = shouldEnable ? '启用' : '禁用'; const confirmation = `确定为 ${this.selectedEntries.size} 个条目 ${action} "${fieldName}" 吗?`; this.batchUpdateEntries({ [field]: shouldEnable }, confirmation); } async batchDeleteEntries() { if (this.selectedEntries.size === 0 || !confirm(`删除 ${this.selectedEntries.size} 个条目?`)) return; await TavernHelper.deleteLorebookEntries(this.currentWorldBook, Array.from(this.selectedEntries)); this.selectedEntries.clear(); this.loadWorldBookEntries(this.currentWorldBook); // Refresh } toggleSelectAll(checked) { this.selectedEntries.clear(); if (checked) this.filteredEntries.forEach(e => this.selectedEntries.add(e.uid)); this.renderEntries(); this.updateSelectionUI(); } updateSelectionUI() { const count = this.selectedEntries.size; this.elements.worldEditorSelectedCount.textContent = `已选择 ${count} 项`; this.elements.worldEditorBatchActions.classList.toggle('active', count > 0); this.elements.worldEditorSelectAll.checked = count > 0 && count === this.filteredEntries.length; this.elements.worldEditorSelectAll.indeterminate = count > 0 && count < this.filteredEntries.length; } updateEntryCount() { this.elements.worldEditorEntryCount.textContent = `条目:${this.entries.length}`; } filterEntries() { const term = this.elements.worldEditorSearchBox.value.toLowerCase(); const searchType = document.getElementById('world-editor-search-type').value; if (!term) { this.filteredEntries = [...this.entries]; } else { this.filteredEntries = this.entries.filter(e => { const targetField = e[searchType] || ''; return targetField.toLowerCase().includes(term); }); } this.renderEntries(); } openCreateModal() { this.currentEditingEntry = null; const entry = { enabled: true, type: 'selective', keys: [], content: '', position: 'at_depth', depth: 4, order: 100, comment: '' }; this.showEditModal('创建新条目', entry); } openEditModal(uid) { const entry = this.entries.find(e => e.uid === uid); if (!entry) return; this.currentEditingEntry = entry; this.showEditModal('编辑条目', entry); } showEditModal(title, entry) { const formHtml = this.getEditFormHtml(entry); showHtmlModal(title, formHtml, { onOk: (dialog) => { this.saveEntry(dialog); return true; // Close the modal } }); } getEditFormHtml(entry) { return `
`; } async saveEntry(dialog) { const formData = this.getFormDataFromModal(dialog); if (this.currentEditingEntry) { await TavernHelper.setLorebookEntries(this.currentWorldBook, [{ ...this.currentEditingEntry, ...formData }]); } else { await TavernHelper.createLorebookEntries(this.currentWorldBook, [formData]); } this.loadWorldBookEntries(this.currentWorldBook); } getFormDataFromModal(dialog) { const data = {}; data.enabled = dialog.find('#world-editor-entry-enabled').is(':checked'); data.type = dialog.find('#world-editor-entry-type').val(); data.keys = dialog.find('#world-editor-entry-keys').val().split('\n').map(k => k.trim()).filter(Boolean); data.content = dialog.find('#world-editor-entry-content').val(); data.position = dialog.find('#world-editor-entry-position').val(); data.depth = parseInt(dialog.find('#world-editor-entry-depth').val()); data.order = parseInt(dialog.find('#world-editor-entry-order').val()); data.comment = dialog.find('#world-editor-entry-comment').val(); data.exclude_recursion = dialog.find('#world-editor-entry-disable-recursion').is(':checked'); data.prevent_recursion = dialog.find('#world-editor-entry-prevent-recursion').is(':checked'); return data; } setLoading(loading) { this.elements.worldEditorEntriesContainer.classList.toggle('loading', loading); } showError(msg) { if (window.toastr) window.toastr.error(msg); console.error(msg); } sortEntries(key) { if (this.sortState.key === key) { this.sortState.asc = !this.sortState.asc; } else { this.sortState.key = key; this.sortState.asc = true; } this.renderEntries(); } sortFilteredEntries() { const { key, asc } = this.sortState; this.filteredEntries.sort((a, b) => { let valA = a[key]; let valB = b[key]; if (typeof valA === 'string') valA = valA.toLowerCase(); if (typeof valB === 'string') valB = valB.toLowerCase(); if (valA < valB) return asc ? -1 : 1; if (valA > valB) return asc ? 1 : -1; return 0; }); } bindExternalEvents() { eventSource.on(event_types.CHAT_CHANGED, () => { console.log('[世界书编辑器] 检测到聊天变更 (CHAT_CHANGED),将自动刷新。'); this.loadAvailableWorldBooks(); }); console.log('[世界书编辑器] 已成功绑定外部事件监听器。'); } } function initializeWorldEditorWhenVisible() { const panel = document.getElementById('amily2_world_editor_panel'); if (!panel) { console.error('[WorldEditor] Panel not found!'); return; } const observer = new MutationObserver(() => { if (panel.style.display !== 'none' && !window.worldEditorInstance) { window.worldEditorInstance = new WorldEditor(); observer.disconnect(); } }); observer.observe(panel, { attributes: true, attributeFilter: ['style'] }); if (panel.style.display !== 'none') { // Check initial state if (!window.worldEditorInstance) window.worldEditorInstance = new WorldEditor(); observer.disconnect(); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeWorldEditorWhenVisible); } else { initializeWorldEditorWhenVisible(); }