From c3bb162a10641123b7b364e5e3b302cccba916b2 Mon Sep 17 00:00:00 2001 From: RT15548 <168917470+RT15548@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:58:05 +0800 Subject: [PATCH] feat: updates to test branch --- bridges/call-generate-service.js | 336 +-- bridges/context-bridge.js | 30 +- core/server-storage.js | 1 + index.js | 126 +- modules/ena-planner/ena-planner-presets.js | 59 + modules/ena-planner/ena-planner.css | 971 +++++-- modules/ena-planner/ena-planner.html | 948 ++++++ modules/ena-planner/ena-planner.js | 3047 ++++++++------------ modules/scheduled-tasks/scheduled-tasks.js | 38 +- modules/story-summary/story-summary.js | 69 +- modules/tts/tts-api.js | 37 +- modules/tts/tts-overlay.html | 82 +- modules/tts/tts-text.js | 88 +- modules/tts/tts.js | 42 +- settings.html | 13 +- 15 files changed, 3596 insertions(+), 2291 deletions(-) create mode 100644 modules/ena-planner/ena-planner-presets.js create mode 100644 modules/ena-planner/ena-planner.html diff --git a/bridges/call-generate-service.js b/bridges/call-generate-service.js index 2800a79..7533e72 100644 --- a/bridges/call-generate-service.js +++ b/bridges/call-generate-service.js @@ -51,7 +51,7 @@ class CallGenerateService { sendError(sourceWindow, requestId, streamingEnabled, err, fallbackCode = 'API_ERROR', details = null, targetOrigin = null) { const e = this.normalizeError(err, fallbackCode, details); const type = streamingEnabled ? 'generateStreamError' : 'generateError'; - try { sourceWindow?.postMessage({ source: SOURCE_TAG, type, id: requestId, error: e }, resolveTargetOrigin(targetOrigin)); } catch {} + try { sourceWindow?.postMessage({ source: SOURCE_TAG, type, id: requestId, error: e }, resolveTargetOrigin(targetOrigin)); } catch { } } /** @@ -260,7 +260,7 @@ class CallGenerateService { postToTarget(target, type, body, targetOrigin = null) { try { target?.postMessage({ source: SOURCE_TAG, type, ...body }, resolveTargetOrigin(targetOrigin)); - } catch (e) {} + } catch (e) { } } // ===== ST Prompt 干跑捕获与组件切换 ===== @@ -352,7 +352,7 @@ class CallGenerateService { const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; const mapSnap = new Map((this._lastToggleSnapshot || snapshot).map(s => [s.identifier, s.enabled])); order.forEach(e => { if (mapSnap.has(e.identifier)) e.enabled = mapSnap.get(e.identifier); }); - } catch {} + } catch { } this._toggleBusy = false; this._lastToggleSnapshot = null; } @@ -416,7 +416,7 @@ class CallGenerateService { } finally { for (const key of Object.keys(oai_settings)) { if (!Object.prototype.hasOwnProperty.call(snapshot, key)) { - try { delete oai_settings[key]; } catch {} + try { delete oai_settings[key]; } catch { } } } Object.assign(oai_settings, snapshot); @@ -503,7 +503,7 @@ class CallGenerateService { try { const nameCache = this._getNameCache(); if (nameCache.has(nm)) return nameCache.get(nm); - } catch {} + } catch { } // 2) 扫描 PromptManager 的订单(显示用) try { @@ -521,7 +521,7 @@ class CallGenerateService { } } } - } catch {} + } catch { } // 3) 扫描 Prompt 集合(运行期合并后的集合) try { @@ -538,7 +538,7 @@ class CallGenerateService { } } } - } catch {} + } catch { } // 4) 失败时尝试 sanitize 名称与 identifier 的弱匹配 if (matches.size === 0) { @@ -555,12 +555,12 @@ class CallGenerateService { } } } - } catch {} + } catch { } } if (matches.size === 1) { const id = Array.from(matches)[0]; - try { this._getNameCache().set(nm, id); } catch {} + try { this._getNameCache().set(nm, id); } catch { } return id; } if (matches.size > 1) { @@ -823,9 +823,9 @@ class CallGenerateService { const capture = await this._captureWithEnabledSet(new Set([key]), '', false); const normSet = new Set(capture.map(x => `[${x.role}] ${this._normStrip(x.content)}`)); footprint.set(key, normSet); - try { fpCache.set(key, normSet); } catch {} + try { fpCache.set(key, normSet); } catch { } } - } catch {} + } catch { } } for (const m of arr) { if (m?.identifier) continue; @@ -1055,7 +1055,7 @@ class CallGenerateService { try { const re = new RegExp(regex); out = out.filter(m => re.test(String(m.content))); - } catch {} + } catch { } } if (fromUserNames && fromUserNames.length) { // 仅当 messages 中附带 name 时生效;否则忽略 @@ -1229,132 +1229,132 @@ class CallGenerateService { // 步骤 2~9 的核心逻辑,可能包裹在 _withTemporaryPreset 中执行 const executeCore = async () => { - // 2) 解析组件列表与内联注入 - const list = Array.isArray(options?.components?.list) ? options.components.list.slice() : undefined; - let baseStrategy = 'EMPTY'; // EMPTY | ALL | ALL_PREON | SUBSET - let orderedRefs = []; - let inlineMapped = []; - let listLevelOverrides = {}; - const unorderedKeys = new Set(); - if (list && list.length) { - const { references, inlineInjections, listOverrides } = this._parseUnifiedList(list); - listLevelOverrides = listOverrides || {}; - const parsedRefs = references.map(t => this._parseComponentRefToken(t)); - const containsAll = parsedRefs.includes('ALL'); - const containsAllPreOn = parsedRefs.includes('ALL_PREON'); - if (containsAll) { - baseStrategy = 'ALL'; - // ALL 仅作为开关标识,子集重排目标为去除 ALL 后的引用列表 - orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); - } else if (containsAllPreOn) { - baseStrategy = 'ALL_PREON'; - // ALL_PREON:仅启用“预设里已开启”的组件,子集重排目标为去除该标记后的引用列表 - orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); - } else { - baseStrategy = 'SUBSET'; - orderedRefs = parsedRefs.filter(Boolean); - } - inlineMapped = this._mapInlineInjectionsUnified(list, inlineInjections); - // 放宽:ALL 可出现在任意位置,作为“启用全部”的标志 - - // 解析 order=false:不参与重排 - for (const rawKey in listLevelOverrides) { - if (!Object.prototype.hasOwnProperty.call(listLevelOverrides, rawKey)) continue; - const k = this._parseComponentRefToken(rawKey); - if (!k) continue; - if (listLevelOverrides[rawKey] && listLevelOverrides[rawKey].order === false) unorderedKeys.add(k); - } - } - - // 3) 干跑捕获(基座) - let captured = []; - let enabledIds = []; // assembleOnly 时用于 identifier 标注 - if (baseStrategy === 'EMPTY') { - captured = []; - } else { - // 不将 userInput 作为 quietText 干跑,以免把其注入到历史里 - if (baseStrategy === 'ALL') { - // 路径B:ALL 时先全开启用集合再干跑,保证真实组件尽量出现 - // 读取 promptManager 订单并构造 allow 集合 - let allow = new Set(); - try { - if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { - const pm = promptManager; - const activeChar = pm?.activeCharacter ?? null; - const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; - allow = new Set(order.map(e => e.identifier)); - } - } catch {} - enabledIds = Array.from(allow); - const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); - captured = await this._withPromptEnabledSet(allow, run); - } else if (baseStrategy === 'ALL_PREON') { - // 仅启用预设里已开启的组件 - let allow = new Set(); - try { - if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { - const pm = promptManager; - const activeChar = pm?.activeCharacter ?? null; - const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; - allow = new Set(order.filter(e => !!e?.enabled).map(e => e.identifier)); - } - } catch {} - enabledIds = Array.from(allow); - const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); - captured = await this._withPromptEnabledSet(allow, run); - } else { - captured = await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); - } - } - - // 4) 依据策略计算启用集合与顺序 - let annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []); - // assembleOnly 模式下,若无显式排序引用,则用全部启用组件做 identifier 标注 - if (assembleOnly && annotateKeys.length === 0 && enabledIds.length > 0) { - annotateKeys = enabledIds; - } - let working = await this._annotateIdentifiersIfMissing(captured.slice(), annotateKeys); - working = this._applyOrderingStrategy(working, baseStrategy, orderedRefs, unorderedKeys); - - // 5) 覆写与创建 - working = this._applyInlineOverrides(working, listLevelOverrides); - - // 6) 注入(内联 + 高级) - working = this._applyAllInjections(working, inlineMapped, options?.injections); - - // 7) 用户输入追加 - working = this._appendUserInput(working, options?.userInput); - - // 8) 调试导出 - this._exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug: options?.debug, targetOrigin }); - - // assembleOnly 模式:只返回组装好的 messages,不调 LLM - if (assembleOnly) { - // 构建 identifier → name 映射(从 promptCollection 取,order 里没有 name) - const idToName = new Map(); - try { - if (promptManager && typeof promptManager.getPromptCollection === 'function') { - const pc = promptManager.getPromptCollection(); - const coll = pc?.collection || []; - for (const p of coll) { - if (p?.identifier) idToName.set(p.identifier, p.name || p.label || p.title || ''); - } + // 2) 解析组件列表与内联注入 + const list = Array.isArray(options?.components?.list) ? options.components.list.slice() : undefined; + let baseStrategy = 'EMPTY'; // EMPTY | ALL | ALL_PREON | SUBSET + let orderedRefs = []; + let inlineMapped = []; + let listLevelOverrides = {}; + const unorderedKeys = new Set(); + if (list && list.length) { + const { references, inlineInjections, listOverrides } = this._parseUnifiedList(list); + listLevelOverrides = listOverrides || {}; + const parsedRefs = references.map(t => this._parseComponentRefToken(t)); + const containsAll = parsedRefs.includes('ALL'); + const containsAllPreOn = parsedRefs.includes('ALL_PREON'); + if (containsAll) { + baseStrategy = 'ALL'; + // ALL 仅作为开关标识,子集重排目标为去除 ALL 后的引用列表 + orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); + } else if (containsAllPreOn) { + baseStrategy = 'ALL_PREON'; + // ALL_PREON:仅启用“预设里已开启”的组件,子集重排目标为去除该标记后的引用列表 + orderedRefs = parsedRefs.filter(x => x && x !== 'ALL' && x !== 'ALL_PREON'); + } else { + baseStrategy = 'SUBSET'; + orderedRefs = parsedRefs.filter(Boolean); } - } catch {} - const messages = working.map(m => { - const id = m.identifier || undefined; - const componentName = id ? (idToName.get(id) || undefined) : undefined; - return { role: m.role, content: m.content, identifier: id, name: componentName }; - }); - this.postToTarget(sourceWindow, 'assemblePromptResult', { - id: requestId, - messages: messages - }, targetOrigin); - return { messages }; - } + inlineMapped = this._mapInlineInjectionsUnified(list, inlineInjections); + // 放宽:ALL 可出现在任意位置,作为“启用全部”的标志 - // 9) 发送 - return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow, targetOrigin); + // 解析 order=false:不参与重排 + for (const rawKey in listLevelOverrides) { + if (!Object.prototype.hasOwnProperty.call(listLevelOverrides, rawKey)) continue; + const k = this._parseComponentRefToken(rawKey); + if (!k) continue; + if (listLevelOverrides[rawKey] && listLevelOverrides[rawKey].order === false) unorderedKeys.add(k); + } + } + + // 3) 干跑捕获(基座) + let captured = []; + let enabledIds = []; // assembleOnly 时用于 identifier 标注 + if (baseStrategy === 'EMPTY') { + captured = []; + } else { + // 不将 userInput 作为 quietText 干跑,以免把其注入到历史里 + if (baseStrategy === 'ALL') { + // 路径B:ALL 时先全开启用集合再干跑,保证真实组件尽量出现 + // 读取 promptManager 订单并构造 allow 集合 + let allow = new Set(); + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + allow = new Set(order.map(e => e.identifier)); + } + } catch { } + enabledIds = Array.from(allow); + const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + captured = await this._withPromptEnabledSet(allow, run); + } else if (baseStrategy === 'ALL_PREON') { + // 仅启用预设里已开启的组件 + let allow = new Set(); + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + allow = new Set(order.filter(e => !!e?.enabled).map(e => e.identifier)); + } + } catch { } + enabledIds = Array.from(allow); + const run = async () => await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + captured = await this._withPromptEnabledSet(allow, run); + } else { + captured = await this._capturePromptMessages({ includeConfig: null, quietText: '', skipWIAN: false }); + } + } + + // 4) 依据策略计算启用集合与顺序 + let annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []); + // assembleOnly 模式下,若无显式排序引用,则用全部启用组件做 identifier 标注 + if (assembleOnly && annotateKeys.length === 0 && enabledIds.length > 0) { + annotateKeys = enabledIds; + } + let working = await this._annotateIdentifiersIfMissing(captured.slice(), annotateKeys); + working = this._applyOrderingStrategy(working, baseStrategy, orderedRefs, unorderedKeys); + + // 5) 覆写与创建 + working = this._applyInlineOverrides(working, listLevelOverrides); + + // 6) 注入(内联 + 高级) + working = this._applyAllInjections(working, inlineMapped, options?.injections); + + // 7) 用户输入追加 + working = this._appendUserInput(working, options?.userInput); + + // 8) 调试导出 + this._exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug: options?.debug, targetOrigin }); + + // assembleOnly 模式:只返回组装好的 messages,不调 LLM + if (assembleOnly) { + // 构建 identifier → name 映射(从 promptCollection 取,order 里没有 name) + const idToName = new Map(); + try { + if (promptManager && typeof promptManager.getPromptCollection === 'function') { + const pc = promptManager.getPromptCollection(); + const coll = pc?.collection || []; + for (const p of coll) { + if (p?.identifier) idToName.set(p.identifier, p.name || p.label || p.title || ''); + } + } + } catch { } + const messages = working.map(m => { + const id = m.identifier || undefined; + const componentName = id ? (idToName.get(id) || undefined) : undefined; + return { role: m.role, content: m.content, identifier: id, name: componentName }; + }); + this.postToTarget(sourceWindow, 'assemblePromptResult', { + id: requestId, + messages: messages + }, targetOrigin); + return { messages }; + } + + // 9) 发送 + return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow, targetOrigin); }; // end executeCore @@ -1433,7 +1433,7 @@ class CallGenerateService { overrides: listLevelOverrides || null, }; this.postToTarget(sourceWindow, 'blueprint', bp, targetOrigin); - } catch {} + } catch { } } } @@ -1451,10 +1451,10 @@ class CallGenerateService { const userInputLen = String(options?.userInput || '').length; xbLog.info('callGenerateBridge', `generateRequest id=${requestId} stream=${!!streamingEnabled} comps=${compsCount} userInputLen=${userInputLen}`); } - } catch {} + } catch { } return await this.handleRequestInternal(options, requestId, sourceWindow, targetOrigin); } catch (err) { - try { xbLog.error('callGenerateBridge', `generateRequest failed id=${requestId}`, err); } catch {} + try { xbLog.error('callGenerateBridge', `generateRequest failed id=${requestId}`, err); } catch { } this.sendError(sourceWindow, requestId, streamingEnabled, err, 'BAD_REQUEST', null, targetOrigin); return null; } @@ -1463,12 +1463,12 @@ class CallGenerateService { /** 取消会话 */ cancel(sessionId) { const s = this.sessions.get(this.normalizeSessionId(sessionId)); - try { s?.abortController?.abort(); } catch {} + try { s?.abortController?.abort(); } catch { } } /** 清理所有会话 */ cleanup() { - this.sessions.forEach(s => { try { s.abortController?.abort(); } catch {} }); + this.sessions.forEach(s => { try { s.abortController?.abort(); } catch { } }); this.sessions.clear(); } } @@ -1486,7 +1486,7 @@ let __xb_generate_listener = null; export function initCallGenerateHostBridge() { if (typeof window === 'undefined') return; if (__xb_generate_listener_attached) return; - try { xbLog.info('callGenerateBridge', 'initCallGenerateHostBridge'); } catch {} + try { xbLog.info('callGenerateBridge', 'initCallGenerateHostBridge'); } catch { } __xb_generate_listener = async function (event) { try { const data = event && event.data || {}; @@ -1528,47 +1528,47 @@ export function initCallGenerateHostBridge() { return; } } catch (e) { - try { xbLog.error('callGenerateBridge', 'listener error', e); } catch {} + try { xbLog.error('callGenerateBridge', 'listener error', e); } catch { } } }; // eslint-disable-next-line no-restricted-syntax -- bridge listener; origin can be null for sandboxed iframes. - try { window.addEventListener('message', __xb_generate_listener); } catch (e) {} + try { window.addEventListener('message', __xb_generate_listener); } catch (e) { } __xb_generate_listener_attached = true; } export function cleanupCallGenerateHostBridge() { if (typeof window === 'undefined') return; if (!__xb_generate_listener_attached) return; - try { xbLog.info('callGenerateBridge', 'cleanupCallGenerateHostBridge'); } catch {} - try { window.removeEventListener('message', __xb_generate_listener); } catch (e) {} + try { xbLog.info('callGenerateBridge', 'cleanupCallGenerateHostBridge'); } catch { } + try { window.removeEventListener('message', __xb_generate_listener); } catch (e) { } __xb_generate_listener_attached = false; __xb_generate_listener = null; - try { callGenerateService.cleanup(); } catch (e) {} + try { callGenerateService.cleanup(); } catch (e) { } } if (typeof window !== 'undefined') { Object.assign(window, { xiaobaixCallGenerateService: callGenerateService, initCallGenerateHostBridge, cleanupCallGenerateHostBridge }); - try { initCallGenerateHostBridge(); } catch (e) {} + try { initCallGenerateHostBridge(); } catch (e) { } try { window.addEventListener('xiaobaixEnabledChanged', (e) => { try { const enabled = e && e.detail && e.detail.enabled === true; if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); - } catch (_) {} + } catch (_) { } }); document.addEventListener('xiaobaixEnabledChanged', (e) => { try { const enabled = e && e.detail && e.detail.enabled === true; if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); - } catch (_) {} + } catch (_) { } }); - window.addEventListener('beforeunload', () => { try { cleanupCallGenerateHostBridge(); } catch (_) {} }); - } catch (_) {} + window.addEventListener('beforeunload', () => { try { cleanupCallGenerateHostBridge(); } catch (_) { } }); + } catch (_) { } // ===== 全局 API 暴露:与 iframe 调用方式完全一致 ===== // 创建命名空间 window.LittleWhiteBox = window.LittleWhiteBox || {}; - + /** * 全局 callGenerate 函数 * 使用方式与 iframe 中完全一致:await window.callGenerate(options) @@ -1593,22 +1593,22 @@ if (typeof window !== 'undefined') { * api: { inherit: true } * }); */ - window.LittleWhiteBox.callGenerate = async function(options) { + window.LittleWhiteBox.callGenerate = async function (options) { return new Promise((resolve, reject) => { const requestId = `global-${Date.now()}-${Math.random().toString(36).slice(2)}`; const streamingEnabled = options?.streaming?.enabled !== false; - + // 处理流式回调 let onChunkCallback = null; if (streamingEnabled && typeof options?.streaming?.onChunk === 'function') { onChunkCallback = options.streaming.onChunk; } - + // 监听响应 const listener = (event) => { const data = event.data; if (!data || data.source !== SOURCE_TAG || data.id !== requestId) return; - + if (data.type === 'generateStreamChunk' && onChunkCallback) { // 流式文本块回调 try { @@ -1627,10 +1627,10 @@ if (typeof window !== 'undefined') { reject(data.error); } }; - + // eslint-disable-next-line no-restricted-syntax -- local listener for internal request flow. window.addEventListener('message', listener); - + // 发送请求 handleGenerateRequest(options, requestId, window).catch(err => { window.removeEventListener('message', listener); @@ -1638,7 +1638,7 @@ if (typeof window !== 'undefined') { }); }); }; - + /** * 全局 assemblePrompt 函数 * 只组装提示词,不调用 LLM,返回组装好的 messages 数组 @@ -1653,7 +1653,7 @@ if (typeof window !== 'undefined') { * }); * // messages = [{ role: 'system', content: '...' }, ...] */ - window.LittleWhiteBox.assemblePrompt = async function(options) { + window.LittleWhiteBox.assemblePrompt = async function (options) { return new Promise((resolve, reject) => { const requestId = `assemble-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -1686,22 +1686,22 @@ if (typeof window !== 'undefined') { * 取消指定会话 * @param {string} sessionId - 会话 ID(如 'xb1', 'xb2' 等) */ - window.LittleWhiteBox.callGenerate.cancel = function(sessionId) { + window.LittleWhiteBox.callGenerate.cancel = function (sessionId) { callGenerateService.cancel(sessionId); }; - + /** * 清理所有会话 */ - window.LittleWhiteBox.callGenerate.cleanup = function() { + window.LittleWhiteBox.callGenerate.cleanup = function () { callGenerateService.cleanup(); }; - - window.LittleWhiteBox.listChatCompletionPresets = function() { + + window.LittleWhiteBox.listChatCompletionPresets = function () { return Object.keys(openai_setting_names || {}); }; - window.LittleWhiteBox.getSelectedPresetName = function() { + window.LittleWhiteBox.getSelectedPresetName = function () { return oai_settings?.preset_settings_openai || ''; }; @@ -1712,4 +1712,4 @@ if (typeof window !== 'undefined') { init: initCallGenerateHostBridge, cleanup: cleanupCallGenerateHostBridge }; -} +} \ No newline at end of file diff --git a/bridges/context-bridge.js b/bridges/context-bridge.js index 730f3c2..c5f314d 100644 --- a/bridges/context-bridge.js +++ b/bridges/context-bridge.js @@ -29,18 +29,18 @@ class ContextBridgeService { init() { if (this._attached) return; - try { xbLog.info('contextBridge', 'init'); } catch {} + try { xbLog.info('contextBridge', 'init'); } catch { } try { this._previousChatId = getCurrentChatId(); - } catch {} + } catch { } const self = this; this._listener = function (event) { try { self._handleMessage(event); } catch (e) { - try { xbLog.error('contextBridge', 'message handler error', e); } catch {} + try { xbLog.error('contextBridge', 'message handler error', e); } catch { } } }; @@ -52,8 +52,8 @@ class ContextBridgeService { cleanup() { if (!this._attached) return; - try { xbLog.info('contextBridge', 'cleanup'); } catch {} - try { window.removeEventListener('message', this._listener); } catch {} + try { xbLog.info('contextBridge', 'cleanup'); } catch { } + try { window.removeEventListener('message', this._listener); } catch { } this._listener = null; this._events.cleanup(); this._pendingEvents.length = 0; @@ -81,14 +81,14 @@ class ContextBridgeService { while (this._pendingEvents.length > 0) { const pending = this._pendingEvents.shift(); // eslint-disable-next-line no-restricted-syntax -- delivering queued events to newly ready iframe - try { event.source?.postMessage(pending, '*'); } catch {} + try { event.source?.postMessage(pending, '*'); } catch { } } } // 推送上下文快照 const snapshot = this._buildContextSnapshot(msgIndex); // eslint-disable-next-line no-restricted-syntax -- sending context snapshot to requesting iframe - try { event.source?.postMessage(snapshot, '*'); } catch {} + try { event.source?.postMessage(snapshot, '*'); } catch { } } /** @@ -102,7 +102,7 @@ class ContextBridgeService { for (const iframe of iframes) { try { if (iframe.contentWindow === source) return iframe; - } catch {} + } catch { } } return null; } @@ -160,7 +160,7 @@ class ContextBridgeService { const message = { type: 'st-event', source: SOURCE_TAG, event: eventName, payload }; for (const iframe of iframes) { // eslint-disable-next-line no-restricted-syntax -- broadcasting event to template iframes - try { iframe.contentWindow?.postMessage(message, '*'); } catch {} + try { iframe.contentWindow?.postMessage(message, '*'); } catch { } } } @@ -271,23 +271,23 @@ if (typeof window !== 'undefined') { window.LittleWhiteBox = window.LittleWhiteBox || {}; window.LittleWhiteBox.contextBridge = contextBridgeService; - try { initContextBridge(); } catch (e) {} + try { initContextBridge(); } catch (e) { } try { window.addEventListener('xiaobaixEnabledChanged', (e) => { try { const enabled = e && e.detail && e.detail.enabled === true; if (enabled) initContextBridge(); else cleanupContextBridge(); - } catch {} + } catch { } }); document.addEventListener('xiaobaixEnabledChanged', (e) => { try { const enabled = e && e.detail && e.detail.enabled === true; if (enabled) initContextBridge(); else cleanupContextBridge(); - } catch {} + } catch { } }); window.addEventListener('beforeunload', () => { - try { cleanupContextBridge(); } catch {} + try { cleanupContextBridge(); } catch { } }); - } catch {} -} + } catch { } +} \ No newline at end of file diff --git a/core/server-storage.js b/core/server-storage.js index 4797524..9d13d8f 100644 --- a/core/server-storage.js +++ b/core/server-storage.js @@ -182,5 +182,6 @@ export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json'); export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json'); export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 }); export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 }); +export const EnaPlannerStorage = new StorageFile('LittleWhiteBox_EnaPlanner.json', { debounceMs: 800 }); export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 }); export const VectorStorage = new StorageFile('LittleWhiteBox_Vectors.json', { debounceMs: 3000 }); diff --git a/index.js b/index.js index fb7014b..eb795ca 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,7 @@ import { initNovelDraw, cleanupNovelDraw } from "./modules/novel-draw/novel-draw import "./modules/story-summary/story-summary.js"; import "./modules/story-outline/story-outline.js"; import { initTts, cleanupTts } from "./modules/tts/tts.js"; -import { initEnaPlanner } from "./modules/ena-planner/ena-planner.js"; +import { initEnaPlanner, cleanupEnaPlanner } from "./modules/ena-planner/ena-planner.js"; extension_settings[EXT_ID] = extension_settings[EXT_ID] || { enabled: true, @@ -45,6 +45,7 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || { storyOutline: { enabled: false }, novelDraw: { enabled: false }, tts: { enabled: false }, + enaPlanner: { enabled: false }, useBlob: false, wrapperIframe: true, renderEnabled: true, @@ -56,7 +57,7 @@ if (settings.dynamicPrompt && !settings.fourthWall) settings.fourthWall = settin const DEPRECATED_KEYS = [ 'characterUpdater', - 'promptSections', + 'promptSections', 'promptPresets', 'relationshipGuidelines', 'scriptAssistant' @@ -65,7 +66,7 @@ const DEPRECATED_KEYS = [ function cleanupDeprecatedData() { const s = extension_settings[EXT_ID]; if (!s) return; - + let cleaned = false; for (const key of DEPRECATED_KEYS) { if (key in s) { @@ -74,7 +75,7 @@ function cleanupDeprecatedData() { console.log(`[LittleWhiteBox] Cleaned deprecated data: ${key}`); } } - + if (cleaned) { saveSettingsDebounced(); console.log('[LittleWhiteBox] Deprecated data cleanup complete'); @@ -195,13 +196,13 @@ function addUpdateDownloadButton() { totalSwitchDivider.style.display = 'flex'; totalSwitchDivider.style.alignItems = 'center'; totalSwitchDivider.style.justifyContent = 'flex-start'; - } catch (e) {} + } catch (e) { } totalSwitchDivider.appendChild(updateButton); try { if (window.setupUpdateButtonInSettings) { window.setupUpdateButtonInSettings(); } - } catch (e) {} + } catch (e) { } } function removeAllUpdateNotices() { @@ -219,7 +220,7 @@ async function performExtensionUpdateCheck() { if (versionData && versionData.isUpToDate === false) { updateExtensionHeaderWithUpdateNotice(); } - } catch (error) {} + } catch (error) { } } function registerModuleCleanup(moduleName, cleanupFunction) { @@ -229,26 +230,26 @@ function registerModuleCleanup(moduleName, cleanupFunction) { function removeSkeletonStyles() { try { document.querySelectorAll('.xiaobaix-skel').forEach(el => { - try { el.remove(); } catch (e) {} + try { el.remove(); } catch (e) { } }); document.getElementById('xiaobaix-skeleton-style')?.remove(); - } catch (e) {} + } catch (e) { } } function cleanupAllResources() { try { EventCenter.cleanupAll(); - } catch (e) {} - try { window.xbDebugPanelClose?.(); } catch (e) {} + } catch (e) { } + try { window.xbDebugPanelClose?.(); } catch (e) { } moduleCleanupFunctions.forEach((cleanupFn) => { try { cleanupFn(); - } catch (e) {} + } catch (e) { } }); moduleCleanupFunctions.clear(); try { cleanupRenderer(); - } catch (e) {} + } catch (e) { } document.querySelectorAll('.memory-button, .mes_history_preview').forEach(btn => btn.remove()); document.querySelectorAll('#message_preview_btn').forEach(btn => { if (btn instanceof HTMLElement) { @@ -277,7 +278,8 @@ function toggleSettingsControls(enabled) { 'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'xiaobaix_variables_mode', 'Wrapperiframe', 'xiaobaix_render_enabled', 'xiaobaix_max_rendered', 'xiaobaix_story_outline_enabled', 'xiaobaix_story_summary_enabled', 'xiaobaix_novel_draw_enabled', 'xiaobaix_novel_draw_open_settings', - 'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings' + 'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings', + 'xiaobaix_ena_planner_enabled', 'xiaobaix_ena_planner_open_settings' ]; controls.forEach(id => { $(`#${id}`).prop('disabled', !enabled).closest('.flex-container').toggleClass('disabled-control', !enabled); @@ -296,11 +298,11 @@ function toggleSettingsControls(enabled) { async function toggleAllFeatures(enabled) { if (enabled) { toggleSettingsControls(true); - try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) {} + try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) { } saveSettingsDebounced(); initRenderer(); - try { initVarCommands(); } catch (e) {} - try { initVareventEditor(); } catch (e) {} + try { initVarCommands(); } catch (e) { } + try { initVareventEditor(); } catch (e) { } if (extension_settings[EXT_ID].tasks?.enabled) { await initTasks(); } @@ -312,6 +314,7 @@ async function toggleAllFeatures(enabled) { { condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore }, { condition: extension_settings[EXT_ID].novelDraw?.enabled, init: initNovelDraw }, { condition: extension_settings[EXT_ID].tts?.enabled, init: initTts }, + { condition: extension_settings[EXT_ID].enaPlanner?.enabled, init: initEnaPlanner }, { condition: true, init: initStreamingGeneration }, { condition: true, init: initButtonCollapse } ]; @@ -328,29 +331,30 @@ async function toggleAllFeatures(enabled) { 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) {} + } 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) {} + } 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) {} + 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 { cleanupTts(); } catch (e) {} - try { clearBlobCaches(); } catch (e) {} + 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 { cleanupTts(); } catch (e) { } + try { cleanupEnaPlanner(); } catch (e) { } + try { clearBlobCaches(); } catch (e) { } toggleSettingsControls(false); - try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) {} - try { window.cleanupCallGenerateHostBridge && window.cleanupCallGenerateHostBridge(); document.getElementById('xb-callgen')?.remove(); } catch (e) {} + 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]); } @@ -391,7 +395,8 @@ async function setupSettings() { { id: 'xiaobaix_story_summary_enabled', key: 'storySummary' }, { id: 'xiaobaix_story_outline_enabled', key: 'storyOutline' }, { id: 'xiaobaix_novel_draw_enabled', key: 'novelDraw', init: initNovelDraw }, - { id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts } + { id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts }, + { id: 'xiaobaix_ena_planner_enabled', key: 'enaPlanner', init: initEnaPlanner } ]; moduleConfigs.forEach(({ id, key, init }) => { @@ -399,13 +404,16 @@ async function setupSettings() { if (!isXiaobaixEnabled) return; const enabled = $(this).prop('checked'); if (!enabled && key === 'fourthWall') { - try { fourthWallCleanup(); } catch (e) {} + try { fourthWallCleanup(); } catch (e) { } } if (!enabled && key === 'novelDraw') { - try { cleanupNovelDraw(); } catch (e) {} + try { cleanupNovelDraw(); } catch (e) { } } if (!enabled && key === 'tts') { - try { cleanupTts(); } catch (e) {} + try { cleanupTts(); } catch (e) { } + } + if (!enabled && key === 'enaPlanner') { + try { cleanupEnaPlanner(); } catch (e) { } } settings[key] = extension_settings[EXT_ID][key] || {}; settings[key].enabled = enabled; @@ -450,6 +458,15 @@ async function setupSettings() { } }); + $("#xiaobaix_ena_planner_open_settings").on("click", function () { + if (!isXiaobaixEnabled) return; + if (settings.enaPlanner?.enabled && window.xiaobaixEnaPlanner?.openSettings) { + window.xiaobaixEnaPlanner.openSettings(); + } else { + toastr.warning('请先启用剧情规划模块'); + } + }); + $("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", async function () { if (!isXiaobaixEnabled) return; settings.useBlob = $(this).prop("checked"); @@ -464,7 +481,7 @@ async function setupSettings() { 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) {} + } catch (e) { } }); $("#xiaobaix_render_enabled").prop("checked", settings.renderEnabled !== false).on("change", async function () { @@ -475,7 +492,7 @@ async function setupSettings() { if (!settings.renderEnabled && wasEnabled) { cleanupRenderer(); } else if (settings.renderEnabled && !wasEnabled) { - initRenderer(); + initRenderer(); setTimeout(() => processExistingMessages(), 100); } }); @@ -495,7 +512,7 @@ async function setupSettings() { $(this).val(v); settings.maxRenderedMessages = v; saveSettingsDebounced(); - try { shrinkRenderedWindowFull(); } catch (e) {} + try { shrinkRenderedWindowFull(); } catch (e) { } }); $(document).off('click.xbreset', '#xiaobaix_reset_btn').on('click.xbreset', '#xiaobaix_reset_btn', function (e) { @@ -512,24 +529,25 @@ async function setupSettings() { variablesPanel: 'xiaobaix_variables_panel_enabled', variablesCore: 'xiaobaix_variables_core_enabled', novelDraw: 'xiaobaix_novel_draw_enabled', - tts: 'xiaobaix_tts_enabled' + tts: 'xiaobaix_tts_enabled', + enaPlanner: 'xiaobaix_ena_planner_enabled' }; const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; - const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; + const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; function setChecked(id, val) { const el = document.getElementById(id); if (el) { el.checked = !!val; - try { $(el).trigger('change'); } catch {} + try { $(el).trigger('change'); } catch { } } } ON.forEach(k => setChecked(MAP[k], true)); OFF.forEach(k => setChecked(MAP[k], false)); setChecked('xiaobaix_use_blob', false); setChecked('Wrapperiframe', true); - try { saveSettingsDebounced(); } catch (e) {} + try { saveSettingsDebounced(); } catch (e) { } }); - } catch (err) {} + } catch (err) { } } function setupDebugButtonInSettings() { @@ -556,7 +574,7 @@ function setupDebugButtonInSettings() { try { const mod = await import('./modules/debug-panel/debug-panel.js'); if (mod?.toggleDebugPanel) await mod.toggleDebugPanel(); - } catch (e) {} + } catch (e) { } }; btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onActivate(); }); btn.addEventListener('keydown', (e) => { @@ -564,7 +582,7 @@ function setupDebugButtonInSettings() { }); row.appendChild(btn); - } catch (e) {} + } catch (e) { } } function setupMenuTabs() { @@ -609,7 +627,7 @@ jQuery(async () => { await setupSettings(); - try { initControlAudio(); } catch (e) {} + try { initControlAudio(); } catch (e) { } if (isXiaobaixEnabled) { initRenderer(); @@ -618,25 +636,25 @@ jQuery(async () => { 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) {} + } 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) {} + } catch (e) { } try { if (isXiaobaixEnabled && !document.getElementById('xb-contextbridge')) document.head.appendChild(Object.assign(document.createElement('script'), { id: 'xb-contextbridge', type: 'module', src: `${extensionFolderPath}/bridges/context-bridge.js` })); - } catch (e) {} + } catch (e) { } eventSource.on(event_types.APP_READY, () => { setTimeout(performExtensionUpdateCheck, 2000); }); if (isXiaobaixEnabled) { - try { initVarCommands(); } catch (e) {} - try { initVareventEditor(); } catch (e) {} + try { initVarCommands(); } catch (e) { } + try { initVareventEditor(); } catch (e) { } if (settings.tasks?.enabled) { try { await initTasks(); } catch (e) { console.error('[Tasks] Init failed:', e); } @@ -650,11 +668,11 @@ jQuery(async () => { { condition: settings.variablesCore?.enabled, init: initVariablesCore }, { condition: settings.novelDraw?.enabled, init: initNovelDraw }, { condition: settings.tts?.enabled, init: initTts }, + { condition: settings.enaPlanner?.enabled, init: initEnaPlanner }, { condition: true, init: initStreamingGeneration }, { condition: true, init: initButtonCollapse } ]; moduleInits.forEach(({ condition, init }) => { if (condition) init(); }); - try { initEnaPlanner(); } catch (e) { console.error('[EnaPlanner] Init failed:', e); } if (settings.preview?.enabled || settings.recorded?.enabled) { setTimeout(initMessagePreview, 1500); @@ -672,7 +690,7 @@ jQuery(async () => { setInterval(() => { if (isXiaobaixEnabled) processExistingMessages(); }, 30000); - } catch (err) {} + } catch (err) { } }); export { executeSlashCommand }; diff --git a/modules/ena-planner/ena-planner-presets.js b/modules/ena-planner/ena-planner-presets.js new file mode 100644 index 0000000..b4043a0 --- /dev/null +++ b/modules/ena-planner/ena-planner-presets.js @@ -0,0 +1,59 @@ +export const DEFAULT_PROMPT_BLOCKS = [ + { + id: 'ena-default-system-001', + role: 'system', + name: 'Ena Planner System', + content: `你是一位剧情规划师(Story Planner)。你的工作是在幕后为互动叙事提供方向指引,而不是直接扮演角色或撰写正文。 + +## 你会收到的信息 + +- 角色卡:当前角色的设定(描述、性格、场景) +- 世界书:世界观设定和规则 +- 剧情摘要:此前发生过的重要事件 +- 聊天历史:最近的 AI 回复片段 +- 向量召回:与当前情境相关的记忆片段 +- 历史规划:之前生成的 块 +- 玩家输入:玩家刚刚发出的指令或行动 + +## 你的任务 + +根据以上所有信息,为下一轮 AI 回复规划剧情走向。 + +## 输出格式(严格遵守) + +只输出以下两个标签,不要输出任何其他内容: + + +(剧情走向指引:接下来应该发生什么。包括场景推进、NPC 反应、事件触发、伏笔推进等。 +写给 AI 看的导演指令,不是给玩家看的正文。简洁、具体、可执行。) + + + +(写作注意事项:这一轮回复应该怎么写。包括叙事节奏、情绪基调、应避免的问题、需要保持的连贯性等。 +同样是给 AI 的元指令,不是正文。) + + +## 规划原则 + +1. 尊重玩家意图:玩家的输入是最高优先级,规划应围绕玩家的行动展开 +2. 保持连贯:与历史 plot 和剧情摘要保持因果一致,不要凭空引入矛盾设定 +3. 推进而非重复:每次规划应让故事向前推进,避免原地踏步 +4. 留有空间:给出方向但不要过度规定细节,让主 AI 有创作余地 +5. 遵守世界观:世界书中的规则和设定是硬约束,不可违反 + +如有思考过程,请放在 中(会被自动剔除)。`, + }, + { + id: 'ena-default-assistant-001', + role: 'assistant', + name: 'Assistant Seed', + content: ` +让我分析当前情境,梳理玩家意图、已有伏笔和世界观约束,然后规划下一步走向... +规划结果输出在......两个块中 +`, + }, +]; + +export const BUILTIN_TEMPLATES = { + '默认模板': DEFAULT_PROMPT_BLOCKS, +}; diff --git a/modules/ena-planner/ena-planner.css b/modules/ena-planner/ena-planner.css index 1e3b9c5..e819880 100644 --- a/modules/ena-planner/ena-planner.css +++ b/modules/ena-planner/ena-planner.css @@ -1,233 +1,844 @@ -/* Ena Planner v0.5 — collapsible, clean */ +/* ═══════════════════════════════════════════════════════════════════════════ + Ena Planner — Settings UI + ═══════════════════════════════════════════════════════════════════════════ */ -/* ===== Settings panel inside inline-drawer ===== */ -#ena_planner_panel { - padding: 8px 0; -} - -#ena_planner_panel .ep-row { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin: 8px 0; -} - -#ena_planner_panel label { - font-size: 12px; - opacity: .9; - display: block; - margin-bottom: 4px; -} - -#ena_planner_panel input[type="text"], -#ena_planner_panel input[type="password"], -#ena_planner_panel input[type="number"], -#ena_planner_panel select, -#ena_planner_panel textarea { - width: 100%; +*, +*::before, +*::after { + margin: 0; + padding: 0; box-sizing: border-box; } -#ena_planner_panel .ep-col { - flex: 1 1 220px; - min-width: 220px; +:root { + --bg: #121212; + --bg2: #1e1e1e; + --bg3: #2a2a2a; + --txt: #e0e0e0; + --txt2: #b0b0b0; + --txt3: #808080; + --bdr: #3a3a3a; + --bdr2: #333; + --acc: #e0e0e0; + --hl: #e8928a; + --hl2: #d87a7a; + --hl-soft: rgba(232, 146, 138, .1); + --inv: #1e1e1e; + --success: #4caf50; + --warn: #ffb74d; + --error: #ef5350; + --code-bg: #0d0d0d; + --code-txt: #d4d4d4; + --radius: 4px; } -#ena_planner_panel .ep-col.wide { - flex: 1 1 100%; - min-width: 260px; +html, +body { + height: auto; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } -/* Tabs */ -#ena_planner_panel .ep-tabs { +body { + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--txt); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Layout + ═══════════════════════════════════════════════════════════════════════════ */ + +.container { display: flex; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 10px; + flex-direction: column; + min-height: 100vh; + padding: 24px 40px; + max-width: 860px; + margin: 0 auto; } -#ena_planner_panel .ep-tab { - padding: 6px 10px; - border-radius: 999px; - cursor: pointer; - border: 1px solid var(--SmartThemeBorderColor, #333); - opacity: .85; - user-select: none; - font-size: 13px; -} +/* ═══════════════════════════════════════════════════════════════════════════ + Header + ═══════════════════════════════════════════════════════════════════════════ */ -#ena_planner_panel .ep-tab.active { - opacity: 1; - background: rgba(255,255,255,.06); -} - -#ena_planner_panel .ep-panel { - display: none; -} - -#ena_planner_panel .ep-panel.active { - display: block; -} - -#ena_planner_panel .ep-actions { +header { display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; + justify-content: space-between; + align-items: flex-start; + padding-bottom: 24px; + border-bottom: 1px solid var(--bdr); + margin-bottom: 24px; } -#ena_planner_panel .ep-hint { - font-size: 11px; - opacity: .7; +.header-left h1 { + font-size: 2rem; + font-weight: 300; + letter-spacing: -.02em; + margin-bottom: 4px; + color: var(--txt); +} + +.header-left h1 span { + font-weight: 600; +} + +.subtitle { + font-size: .75rem; + color: var(--txt3); + letter-spacing: .08em; + text-transform: uppercase; +} + +.stats { + display: flex; + gap: 40px; + align-items: center; + text-align: right; +} + +.stat-val { + font-size: 1.125rem; + font-weight: 500; + line-height: 1.2; + color: var(--txt); +} + +.stat-val .hl { + color: var(--hl); +} + +.stat-lbl { + font-size: .6875rem; + color: var(--txt3); + text-transform: uppercase; + letter-spacing: .1em; margin-top: 4px; } -#ena_planner_panel .ep-hint-box { - font-size: 12px; - opacity: .85; - margin: 10px 0; - padding: 10px; - border-radius: 8px; - background: rgba(255,255,255,.04); - border: 1px solid rgba(255,255,255,.08); - line-height: 1.6; -} - -#ena_planner_panel .ep-divider { - margin: 10px 0; - border-top: 1px dashed rgba(255,255,255,.15); -} - -/* Inline badge (in drawer header) */ -.ep-badge-inline { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - opacity: .9; - margin-left: 8px; -} - -.ep-badge-inline .dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #888; - display: inline-block; -} - -.ep-badge-inline.ok .dot { background: #2ecc71; } -.ep-badge-inline.warn .dot { background: #f39c12; } - -/* Prompt block */ -.ep-prompt-block { - border: 1px solid rgba(255,255,255,.12); - border-radius: 10px; - padding: 10px; - margin: 10px 0; -} - -.ep-prompt-head { +.modal-close { + width: 36px; + height: 36px; display: flex; - gap: 8px; - flex-wrap: wrap; align-items: center; - justify-content: space-between; - margin-bottom: 8px; + justify-content: center; + background: transparent; + border: 1px solid var(--bdr); + border-radius: var(--radius); + cursor: pointer; + transition: border-color .2s; + margin-left: 16px; } -/* ===== Log modal ===== */ -.ep-log-modal { +.modal-close:hover { + border-color: var(--txt2); +} + +.modal-close svg { + width: 16px; + height: 16px; + color: var(--txt2); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Nav Tabs (desktop) + ═══════════════════════════════════════════ */ + +.nav-tabs { + display: flex; + gap: 24px; + border-bottom: 1px solid var(--bdr); + margin-bottom: 24px; +} + +.nav-item { + font-size: .8125rem; + font-weight: 500; + color: var(--txt3); + text-transform: uppercase; + letter-spacing: .08em; + padding-bottom: 12px; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color .2s, border-color .2s; + user-select: none; +} + +.nav-item:hover { + color: var(--txt2); +} + +.nav-item.active { + color: var(--hl); + border-bottom-color: var(--hl); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Mobile Nav (bottom) + ═══════════════════════════════════════════════════════════════════════════ */ + +.mobile-nav { + display: none; position: fixed; - inset: 0; - background: rgba(0,0,0,.65); - z-index: 99999; + bottom: 0; + left: 0; + right: 0; + height: 56px; + background: var(--bg2); + border-top: 1px solid var(--bdr); + z-index: 100; +} + +.mobile-nav-inner { + display: flex; + height: 100%; +} + +.mobile-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + color: var(--txt3); + font-size: .625rem; + text-transform: uppercase; + letter-spacing: .05em; + cursor: pointer; + user-select: none; + transition: color .2s; +} + +.mobile-nav-item span { + line-height: 1; +} + +.mobile-nav-item .nav-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: transparent; + transition: background .2s; + margin-bottom: 2px; +} + +.mobile-nav-item.active { + color: var(--hl); +} + +.mobile-nav-item.active .nav-dot { + background: var(--hl); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Views + ═══════════════════════════════════════════════════════════════════════════ */ + +.view { display: none; } -.ep-log-modal.open { display: block; } - -.ep-log-modal .ep-log-card { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%,-50%); - width: min(980px, 96vw); - height: min(82vh, 900px); - background: rgba(20,20,20,.95); - border: 1px solid rgba(255,255,255,.15); - border-radius: 12px; - padding: 14px; - display: flex; - flex-direction: column; +.view.active { + display: block; + animation: fadeIn .25s ease; } -.ep-log-modal .ep-log-head { +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Cards + ═══════════════════════════════════════════════════════════════════════════ */ + +.card { + background: var(--bg2); + border: 1px solid var(--bdr); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 20px; +} + +.card-title { + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .12em; + color: var(--txt2); + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--bdr2); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Forms + ═══════════════════════════════════════════════════════════════════════════ */ + +.form-row { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 180px; + margin-bottom: 16px; +} + +.form-row .form-group { + margin-bottom: 0; +} + +.form-row+.form-row { + margin-top: 16px; +} + +.form-label { + font-size: .6875rem; + color: var(--txt3); + text-transform: uppercase; + letter-spacing: .06em; +} + +.form-hint { + font-size: .75rem; + color: var(--txt3); + line-height: 1.5; + margin-top: 4px; +} + +.input { + width: 100%; + padding: 9px 12px; + background: var(--bg3); + border: 1px solid var(--bdr); + border-radius: var(--radius); + font-size: .8125rem; + color: var(--txt); + font-family: inherit; + outline: none; + transition: border-color .2s; +} + +.input:focus { + border-color: var(--txt2); +} + +.input::placeholder { + color: var(--txt3); +} + +select.input { + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' fill='none' stroke='%23808080' stroke-width='2'%3E%3Cpolyline points='2 3.5 5 6.5 8 3.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 28px; + cursor: pointer; +} + +textarea.input { + min-height: 80px; + resize: vertical; +} + +.input-row { display: flex; - justify-content: space-between; - align-items: center; gap: 8px; +} + +.input-row .input { + flex: 1; + min-width: 0; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Buttons + ═══════════════════════════════════════════ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 9px 18px; + background: var(--bg2); + color: var(--txt); + border: 1px solid var(--bdr); + border-radius: var(--radius); + font-size: .8125rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: border-color .2s, background .2s; + white-space: nowrap; +} + +.btn:hover { + border-color: var(--txt3); + background: var(--bg3); +} + +.btn:disabled { + opacity: .35; + cursor: not-allowed; +} + +.btn-p { + background: var(--acc); + color: var(--inv); + border-color: var(--acc); +} + +.btn-p:hover { + background: var(--txt2); + border-color: var(--txt2); +} + +.btn-del { + color: var(--hl); + border-color: rgba(232, 146, 138, .3); +} + +.btn-del:hover { + background: var(--hl-soft); + border-color: var(--hl); +} + +.btn-sm { + padding: 5px 12px; + font-size: .75rem; +} + +.btn-group { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Tip Box + ═══════════════════════════════════════════════════════════════════════════ */ + +.tip-box { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 16px; + background: var(--hl-soft); + border: 1px solid var(--bdr); + border-left: 3px solid var(--hl); + border-radius: var(--radius); + margin-bottom: 20px; +} + +.tip-icon { + flex-shrink: 0; + font-size: .875rem; + line-height: 1.6; +} + +.tip-text { + font-size: .8125rem; + color: var(--txt2); + line-height: 1.6; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Prompt Blocks + ═══════════════════════════════════════════════════════════════════════════ */ + +.prompt-block { + background: var(--bg3); + border: 1px solid var(--bdr); + border-radius: var(--radius); + padding: 16px; margin-bottom: 10px; } -.ep-log-modal .ep-log-head .title { - font-weight: 700; - font-size: 15px; -} - -.ep-log-modal .ep-log-body { - overflow: auto; - flex: 1 1 auto; - border: 1px solid rgba(255,255,255,.08); - border-radius: 10px; - padding: 10px; -} - -.ep-log-item { - border-bottom: 1px solid rgba(255,255,255,.08); - padding: 12px 0; -} - -.ep-log-item:last-child { border-bottom: none; } - -.ep-log-item .meta { +.prompt-head { display: flex; justify-content: space-between; + align-items: flex-start; gap: 10px; + margin-bottom: 10px; flex-wrap: wrap; - opacity: .85; - font-size: 12px; +} + +.prompt-head-left { + display: flex; + gap: 8px; + flex: 1; + min-width: 200px; +} + +.prompt-head-right { + display: flex; + gap: 6px; +} + +.prompt-block textarea.input { + min-height: 120px; + font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace; + font-size: .75rem; + line-height: 1.5; +} + +.prompt-empty { + text-align: center; + padding: 36px 20px; + color: var(--txt3); + font-size: .8125rem; + border: 1px dashed var(--bdr); + border-radius: var(--radius); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Undo Bar + ═══════════════════════════════════════════════════════════════════════════ */ + +.undo-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + margin-top: 12px; + background: var(--hl-soft); + border: 1px solid var(--bdr); + border-radius: var(--radius); + font-size: .8125rem; + color: var(--txt2); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Status Text + ═══════════════════════════════════════════ */ + +.status-text { + font-size: .75rem; + color: var(--txt3); + margin-top: 10px; + min-height: 1em; +} + +.status-text.success { + color: var(--success); +} + +.status-text.error { + color: var(--error); +} + +.status-text.loading { + color: var(--warn); +} + +/* ═══════════════════════════════════════════ + Logs + ═══════════════════════════════════════════════════════════════════════════ */ + +.log-list { + max-height: 60vh; + overflow-y: auto; + border: 1px solid var(--bdr); + border-radius: var(--radius); + background: var(--bg3); +} + +.log-item { + padding: 14px 16px; + border-bottom: 1px solid var(--bdr2); +} + +.log-item:last-child { + border-bottom: none; +} + +.log-meta { + display: flex; + justify-content: space-between; + font-size: .6875rem; + color: var(--txt3); + text-transform: uppercase; + letter-spacing: .04em; margin-bottom: 8px; } -.ep-log-item .ep-log-error { - color: #ffb3b3; - font-size: 12px; +.log-meta .success { + color: var(--success); +} + +.log-meta .error { + color: var(--error); +} + +.log-error { + color: var(--error); + font-size: .8125rem; + margin-bottom: 8px; white-space: pre-wrap; +} + +.log-pre { + background: var(--code-bg); + color: var(--code-txt); + padding: 12px; + border-radius: var(--radius); + font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace; + font-size: .6875rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 280px; + overflow-y: auto; + margin-top: 6px; +} + +.log-empty { + text-align: center; + padding: 36px 20px; + color: var(--txt3); + font-size: .8125rem; +} + +details { margin-bottom: 6px; } -.ep-log-item details { - margin: 6px 0; +details:last-child { + margin-bottom: 0; } -.ep-log-item details summary { +details summary { cursor: pointer; - font-size: 12px; - opacity: .85; + font-size: .75rem; + font-weight: 500; + color: var(--txt3); + user-select: none; padding: 4px 0; + transition: color .15s; } -/* Issue #3: proper log formatting with line breaks */ -.ep-log-pre { +details summary:hover { + color: var(--txt); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Debug Output + ═══════════════════════════════════════════════════════════════════════════ */ + +.debug-output { + background: var(--code-bg); + color: var(--code-txt); + padding: 14px; + border-radius: var(--radius); + font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace; + font-size: .6875rem; + line-height: 1.6; + margin-top: 16px; + max-height: 400px; + overflow-y: auto; white-space: pre-wrap; word-break: break-word; - font-size: 12px; - line-height: 1.5; - padding: 10px; - border-radius: 8px; - background: rgba(255,255,255,.04); - border: 1px solid rgba(255,255,255,.06); - max-height: 400px; - overflow: auto; + display: none; } + +.debug-output.visible { + display: block; +} + +/* ═══════════════════════════════════════════ + Utilities + ═══════════════════════════════════════════════════════════════════════════ */ + +.hidden { + display: none !important; +} + +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--bdr); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--txt3); +} + +/* ═══════════════════════════════════════════ + Responsive — Tablet + ═══════════════════════════════════════════ */ + +@media (max-width: 768px) { + .container { + padding: 16px; + } + + header { + flex-direction: column; + gap: 16px; + } + + .header-left h1 { + font-size: 1.5rem; + } + + .stats { + width: 100%; + justify-content: flex-start; + gap: 24px; + } + + .modal-close { + position: absolute; + top: 16px; + right: 16px; + margin-left: 0; + } + + .nav-tabs { + display: none; + } + + .mobile-nav { + display: block; + } + + .container { + padding-bottom: 72px; + } + + .form-row { + flex-direction: column; + gap: 0; + } + + .card { + padding: 16px; + } + + .prompt-head { + flex-direction: column; + } + + .prompt-head-left { + min-width: 0; + flex-direction: column; + } +} + +/* ═══════════════════════════════════════════ + Responsive — Small phone + ═══════════════════════════════════════════════════════════════════════════ */ + +@media (max-width: 480px) { + .container { + padding: 12px; + padding-bottom: 68px; + } + + header { + gap: 12px; + padding-bottom: 16px; + margin-bottom: 16px; + } + + .header-left h1 { + font-size: 1.25rem; + } + + .subtitle { + font-size: .625rem; + } + + .stats { + gap: 16px; + } + + .stat-val { + font-size: 1rem; + } + + .card { + padding: 14px; + margin-bottom: 14px; + } + + .btn-group { + flex-direction: column; + } + + .btn-group .btn { + width: 100%; + } + + .mobile-nav { + height: 52px; + } + + .mobile-nav-item { + font-size: .5625rem; + } +} + +/* ═══════════════════════════════════════════ + Touch devices — 44px minimum target + ═══════════════════════════════════════════════════════════════════════════ */ + +@media (hover: none) and (pointer: coarse) { + .btn { + min-height: 44px; + padding: 10px 18px; + } + + .btn-sm { + min-height: 40px; + } + + .input { + min-height: 44px; + padding: 10px 12px; + } + + .nav-item { + padding-bottom: 14px; + } + + .mobile-nav-item { + min-height: 44px; + } + + .modal-close { + width: 44px; + height: 44px; + } + + details summary { + padding: 8px 0; + } +} \ No newline at end of file diff --git a/modules/ena-planner/ena-planner.html b/modules/ena-planner/ena-planner.html new file mode 100644 index 0000000..bb7309e --- /dev/null +++ b/modules/ena-planner/ena-planner.html @@ -0,0 +1,948 @@ + + + + + + + + + Ena Planner + + + + +
+ +
+
+

EnaPlanner

+
Story Planning · LLM Integration
+
+
+
+
未启用
+
状态
+
+
+
就绪
+
保存
+
+ +
+
+ + + + +
+ + +
+
+
+
+ 工作流程:点击发送 → 拦截 → 收集上下文(角色卡、世界书、摘要、历史 plot、最近 AI 回复)→ 发给规划 LLM → 提取 <plot> 和 + <note> → 追加到你的输入 → 放行发送 +
+
+ +
+
基本设置
+
+
+ + +
+
+ + +
+
+

输入中已有 <plot> 标签时跳过自动规划。

+
+ +
+
快速测试
+
+ + +
+
+ +
+
+
+
+ + +
+
+
连接设置
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+
生成参数
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
💡
+
+ 系统会自动在提示词之后注入:角色卡、世界书、剧情摘要、聊天历史、向量召回等上下文。你只需专注编写"规划指令"。 +
+
+ +
+
模板管理
+
+
+ +
+
+
+ + + +
+
+
+ +
+ +
+
提示词块
+
+ +
+ + +
+
+
+ + +
+
+
世界书
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
聊天与历史
+
+ + +

仅支持英文标签(如 plot, note, memory)。留空表示不按标签过滤(仅去除 think)。无效标签会自动忽略。

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
诊断工具
+
+ + + +
+

+        
+ +
+
日志
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
暂无日志
+
+
+
+ +
+ + + + +
+ + + + + diff --git a/modules/ena-planner/ena-planner.js b/modules/ena-planner/ena-planner.js index c925b3a..565829d 100644 --- a/modules/ena-planner/ena-planner.js +++ b/modules/ena-planner/ena-planner.js @@ -1,336 +1,365 @@ -import { extension_settings } from '../../../../../extensions.js'; +import { extension_settings } from '../../../../../extensions.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js'; import { getStorySummaryForEna } from '../story-summary/story-summary.js'; +import { buildVectorPromptText } from '../story-summary/generate/prompt.js'; +import { getVectorConfig } from '../story-summary/data/config.js'; +import { extensionFolderPath } from '../../core/constants.js'; +import { EnaPlannerStorage } from '../../core/server-storage.js'; +import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js'; +import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js'; const EXT_NAME = 'ena-planner'; +const OVERLAY_ID = 'xiaobaix-ena-planner-overlay'; +const HTML_PATH = `${extensionFolderPath}/modules/ena-planner/ena-planner.html`; +const VECTOR_RECALL_TIMEOUT_MS = 15000; +const PLANNER_REQUEST_TIMEOUT_MS = 90000; -/** ------------------------- +/** + * ------------------------- * Default settings - * --------------------------*/ + * -------------------------- + */ function getDefaultSettings() { - return { - enabled: true, - skipIfPlotPresent: true, + return { + enabled: true, + skipIfPlotPresent: true, - // Chat history: tags to strip from AI responses (besides ) - chatExcludeTags: ['行动选项', 'UpdateVariable', 'StatusPlaceHolderImpl'], + // Chat history: tags to strip from AI responses (besides ) + chatExcludeTags: ['行动选项', 'UpdateVariable', 'StatusPlaceHolderImpl'], - // Worldbook: always read character-linked lorebooks by default - // User can also opt-in to include global worldbooks - includeGlobalWorldbooks: false, - excludeWorldbookPosition4: true, - // Worldbook entry names containing these strings will be excluded - worldbookExcludeNames: ['mvu_update'], + // Worldbook: always read character-linked lorebooks by default + // User can also opt-in to include global worldbooks + includeGlobalWorldbooks: false, + excludeWorldbookPosition4: true, + // Worldbook entry names containing these strings will be excluded + worldbookExcludeNames: ['mvu_update'], - // Plot extraction - plotCount: 2, + // Plot extraction + plotCount: 2, + // Planner response tags to keep, in source order (empty = keep full response) + responseKeepTags: ['plot', 'note', 'plot-log', 'state'], - // Planner prompts (designer) - promptBlocks: [ - { - id: crypto?.randomUUID?.() ?? String(Date.now()), - role: 'system', - name: 'Ena Planner System', - content: `(把你的"规划的提示词.txt"粘贴到这里) -要求:输出 ......,如有思考请放在 ...(会被自动剔除)。` - }, - { - id: crypto?.randomUUID?.() ?? String(Date.now() + 1), - role: 'assistant', - name: 'Assistant Seed (optional)', - content: '' - } - ], - // Saved prompt templates: { name: promptBlocks[] } - promptTemplates: {}, + // Planner prompts (designer) + promptBlocks: structuredClone(DEFAULT_PROMPT_BLOCKS), + // Saved prompt templates: { name: promptBlocks[] } + promptTemplates: structuredClone(BUILTIN_TEMPLATES), + // Currently selected prompt template name in UI + activePromptTemplate: '', - // Planner API - api: { - channel: 'openai', - baseUrl: '', - prefixMode: 'auto', - customPrefix: '', - apiKey: '', - model: '', - stream: false, - temperature: 1, - top_p: 1, - top_k: 0, - presence_penalty: '', - frequency_penalty: '', - max_tokens: '' - }, + // Planner API + api: { + channel: 'openai', + baseUrl: '', + prefixMode: 'auto', + customPrefix: '', + apiKey: '', + model: '', + stream: true, + temperature: 1, + top_p: 1, + top_k: 0, + presence_penalty: '', + frequency_penalty: '', + max_tokens: '' + }, - // Logs - logsPersist: true, - logsMax: 20 - }; -} - -/** ------------------------- - * Local state - * --------------------------*/ -const state = { - isPlanning: false, - bypassNextSend: false, - lastInjectedText: '', - logs: [] -}; - -/** ------------------------- - * Helpers - * --------------------------*/ -function ensureSettings() { - extension_settings[EXT_NAME] = extension_settings[EXT_NAME] ?? getDefaultSettings(); - const d = getDefaultSettings(); - const s = extension_settings[EXT_NAME]; - - function deepMerge(target, src) { - for (const k of Object.keys(src)) { - if (src[k] && typeof src[k] === 'object' && !Array.isArray(src[k])) { - target[k] = target[k] ?? {}; - deepMerge(target[k], src[k]); - } else if (target[k] === undefined) { - target[k] = src[k]; - } - } - } - deepMerge(s, d); - - // Migration: remove old keys that are no longer needed - delete s.includeCharacterLorebooks; - delete s.includeCharDesc; - delete s.includeCharPersonality; - delete s.includeCharScenario; - delete s.includeVectorRecall; - delete s.historyMessageCount; - delete s.worldbookActivationMode; - - return s; -} - -function toastInfo(msg) { - if (window.toastr?.info) return window.toastr.info(msg); - console.log('[EnaPlanner]', msg); -} -function toastWarn(msg) { - if (window.toastr?.warning) return window.toastr.warning(msg); - console.warn('[EnaPlanner]', msg); -} -function toastErr(msg) { - if (window.toastr?.error) return window.toastr.error(msg); - console.error('[EnaPlanner]', msg); -} - -function clampLogs() { - const s = ensureSettings(); - if (state.logs.length > s.logsMax) state.logs = state.logs.slice(0, s.logsMax); -} - -function persistLogsMaybe() { - const s = ensureSettings(); - if (!s.logsPersist) return; - try { - localStorage.setItem('ena_planner_logs', JSON.stringify(state.logs.slice(0, s.logsMax))); - } catch {} -} - -function loadPersistedLogsMaybe() { - const s = ensureSettings(); - if (!s.logsPersist) return; - try { - const raw = localStorage.getItem('ena_planner_logs'); - if (raw) state.logs = JSON.parse(raw) || []; - } catch { - state.logs = []; - } -} - -function nowISO() { - return new Date().toISOString(); -} - -function normalizeUrlBase(u) { - if (!u) return ''; - return u.replace(/\/+$/g, ''); -} - -function getDefaultPrefixByChannel(channel) { - if (channel === 'gemini') return '/v1beta'; - return '/v1'; -} - -function buildApiPrefix() { - const s = ensureSettings(); - if (s.api.prefixMode === 'custom' && s.api.customPrefix?.trim()) return s.api.customPrefix.trim(); - return getDefaultPrefixByChannel(s.api.channel); -} - -function buildUrl(path) { - const s = ensureSettings(); - const base = normalizeUrlBase(s.api.baseUrl); - const prefix = buildApiPrefix(); - const p = prefix.startsWith('/') ? prefix : `/${prefix}`; - const finalPrefix = p.replace(/\/+$/g, ''); - const finalPath = path.startsWith('/') ? path : `/${path}`; - return `${base}${finalPrefix}${finalPath}`; -} - -function setSendUIBusy(busy) { - const sendBtn = document.getElementById('send_but') || document.getElementById('send_button'); - const textarea = document.getElementById('send_textarea'); - if (sendBtn) sendBtn.disabled = !!busy; - if (textarea) textarea.disabled = !!busy; -} - -function escapeHtml(s) { - return String(s ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); + // Logs + logsPersist: true, + logsMax: 20 + }; } /** - * Universal tap handler — works on both desktop (click) and mobile (touch). - * Prevents ghost double-fires by tracking the last trigger time. - * On touch devices, fires on touchend for zero delay; on desktop, fires on click. + * ------------------------- + * Local state + * -------------------------- */ -function _addUniversalTap(el, fn) { - if (!el) return; - let lastTrigger = 0; - const guard = (e) => { - const now = Date.now(); - if (now - lastTrigger < 400) return; // debounce - lastTrigger = now; - e.preventDefault(); - e.stopPropagation(); - fn(e); - }; - el.addEventListener('click', guard); - el.addEventListener('touchend', guard, { passive: false }); +const state = { + isPlanning: false, + bypassNextSend: false, + lastInjectedText: '', + logs: [] +}; + +let config = null; +let overlay = null; +let iframeMessageBound = false; +let sendListenersInstalled = false; +let sendClickHandler = null; +let sendKeydownHandler = null; + +/** + * ------------------------- + * Helpers + * -------------------------- + */ +function ensureSettings() { + const d = getDefaultSettings(); + const s = config || structuredClone(d); + + function deepMerge(target, src) { + for (const k of Object.keys(src)) { + if (src[k] && typeof src[k] === 'object' && !Array.isArray(src[k])) { + target[k] = target[k] ?? {}; + deepMerge(target[k], src[k]); + } else if (target[k] === undefined) { + target[k] = src[k]; + } + } + } + deepMerge(s, d); + if (!Array.isArray(s.responseKeepTags)) s.responseKeepTags = structuredClone(d.responseKeepTags); + else s.responseKeepTags = normalizeResponseKeepTags(s.responseKeepTags); + + // Migration: remove old keys that are no longer needed + delete s.includeCharacterLorebooks; + delete s.includeCharDesc; + delete s.includeCharPersonality; + delete s.includeCharScenario; + delete s.includeVectorRecall; + delete s.historyMessageCount; + delete s.worldbookActivationMode; + + config = s; + return s; +} + +function normalizeResponseKeepTags(tags) { + const src = Array.isArray(tags) ? tags : []; + const cleaned = []; + for (const raw of src) { + const t = String(raw || '') + .trim() + .replace(/^<+|>+$/g, '') + .toLowerCase(); + if (!/^[a-z][a-z0-9_-]*$/.test(t)) continue; + if (!cleaned.includes(t)) cleaned.push(t); + } + return cleaned; +} + +async function loadConfig() { + const loaded = await EnaPlannerStorage.get('config', null); + config = (loaded && typeof loaded === 'object') ? loaded : getDefaultSettings(); + ensureSettings(); + state.logs = Array.isArray(await EnaPlannerStorage.get('logs', [])) ? await EnaPlannerStorage.get('logs', []) : []; + + if (extension_settings?.[EXT_NAME]) { + delete extension_settings[EXT_NAME]; + saveSettingsDebounced?.(); + } + return config; +} + +async function saveConfigNow() { + ensureSettings(); + await EnaPlannerStorage.set('config', config); + await EnaPlannerStorage.set('logs', state.logs); + try { + return await EnaPlannerStorage.saveNow({ silent: false }); + } catch { + return false; + } +} + +function toastInfo(msg) { + if (window.toastr?.info) return window.toastr.info(msg); + console.log('[EnaPlanner]', msg); +} +function toastErr(msg) { + if (window.toastr?.error) return window.toastr.error(msg); + console.error('[EnaPlanner]', msg); +} + +function clampLogs() { + const s = ensureSettings(); + if (state.logs.length > s.logsMax) state.logs = state.logs.slice(0, s.logsMax); +} + +function persistLogsMaybe() { + const s = ensureSettings(); + if (!s.logsPersist) return; + state.logs = state.logs.slice(0, s.logsMax); + EnaPlannerStorage.set('logs', state.logs).catch(() => {}); +} + +function loadPersistedLogsMaybe() { + const s = ensureSettings(); + if (!s.logsPersist) state.logs = []; +} + +function nowISO() { + return new Date().toISOString(); +} + +function runWithTimeout(taskFactory, timeoutMs, timeoutMessage) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + Promise.resolve() + .then(taskFactory) + .then(resolve) + .catch(reject) + .finally(() => clearTimeout(timer)); + }); +} + +function normalizeUrlBase(u) { + if (!u) return ''; + return u.replace(/\/+$/g, ''); +} + +function getDefaultPrefixByChannel(channel) { + if (channel === 'gemini') return '/v1beta'; + return '/v1'; +} + +function buildApiPrefix() { + const s = ensureSettings(); + if (s.api.prefixMode === 'custom' && s.api.customPrefix?.trim()) return s.api.customPrefix.trim(); + return getDefaultPrefixByChannel(s.api.channel); +} + +function buildUrl(path) { + const s = ensureSettings(); + const base = normalizeUrlBase(s.api.baseUrl); + const prefix = buildApiPrefix(); + const p = prefix.startsWith('/') ? prefix : `/${prefix}`; + const finalPrefix = p.replace(/\/+$/g, ''); + const finalPath = path.startsWith('/') ? path : `/${path}`; + const escapedPrefix = finalPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const hasSameSuffix = !!finalPrefix && new RegExp(`${escapedPrefix}$`, 'i').test(base); + const normalizedBase = hasSameSuffix ? base.slice(0, -finalPrefix.length) : base; + return `${normalizedBase}${finalPrefix}${finalPath}`; +} + +function setSendUIBusy(busy) { + const sendBtn = document.getElementById('send_but') || document.getElementById('send_button'); + const textarea = document.getElementById('send_textarea'); + if (sendBtn) sendBtn.disabled = !!busy; + if (textarea) textarea.disabled = !!busy; } function safeStringify(val) { - if (val == null) return ''; - if (typeof val === 'string') return val; - try { return JSON.stringify(val, null, 2); } catch { return String(val); } + if (val == null) return ''; + if (typeof val === 'string') return val; + try { return JSON.stringify(val, null, 2); } catch { return String(val); } } -/** ------------------------- +/** + * ------------------------- * ST context helpers - * --------------------------*/ + * -------------------------- + */ function getContextSafe() { - try { return window.SillyTavern?.getContext?.() ?? null; } catch { return null; } + try { return window.SillyTavern?.getContext?.() ?? null; } catch { return null; } } function getCurrentCharSafe() { - try { - // Method 1: via getContext() - const ctx = getContextSafe(); - if (ctx) { - const cid = ctx.characterId ?? ctx.this_chid; - const chars = ctx.characters; - if (chars && cid != null && chars[cid]) return chars[cid]; - } - // Method 2: global this_chid + characters - const st = window.SillyTavern; - if (st) { - const chid = st.this_chid ?? window.this_chid; - const chars = st.characters ?? window.characters; - if (chars && chid != null && chars[chid]) return chars[chid]; - } - // Method 3: bare globals (some ST versions) - if (window.this_chid != null && window.characters) { - return window.characters[window.this_chid] ?? null; - } - } catch {} - return null; + try { + // Method 1: via getContext() + const ctx = getContextSafe(); + if (ctx) { + const cid = ctx.characterId ?? ctx.this_chid; + const chars = ctx.characters; + if (chars && cid != null && chars[cid]) return chars[cid]; + } + // Method 2: global this_chid + characters + const st = window.SillyTavern; + if (st) { + const chid = st.this_chid ?? window.this_chid; + const chars = st.characters ?? window.characters; + if (chars && chid != null && chars[chid]) return chars[chid]; + } + // Method 3: bare globals (some ST versions) + if (window.this_chid != null && window.characters) { + return window.characters[window.this_chid] ?? null; + } + } catch { } + return null; } -/** ------------------------- +/** + * ------------------------- * Character card — always include desc/personality/scenario - * --------------------------*/ + * -------------------------- + */ function formatCharCardBlock(charObj) { - if (!charObj) return ''; - const name = charObj?.name ?? ''; - const description = charObj?.description ?? ''; - const personality = charObj?.personality ?? ''; - const scenario = charObj?.scenario ?? ''; + if (!charObj) return ''; + const name = charObj?.name ?? ''; + const description = charObj?.description ?? ''; + const personality = charObj?.personality ?? ''; + const scenario = charObj?.scenario ?? ''; - const parts = []; - parts.push(`【角色卡】${name}`.trim()); - if (description) parts.push(`【description】\n${description}`); - if (personality) parts.push(`【personality】\n${personality}`); - if (scenario) parts.push(`【scenario】\n${scenario}`); - return parts.join('\n\n'); + const parts = []; + parts.push(`【角色卡】${name}`.trim()); + if (description) parts.push(`【description】\n${description}`); + if (personality) parts.push(`【personality】\n${personality}`); + if (scenario) parts.push(`【scenario】\n${scenario}`); + return parts.join('\n\n'); } -/** ------------------------- +/** + * ------------------------- * Chat history — ALL unhidden, AI responses ONLY * Strip: unclosed think blocks, configurable tags - * --------------------------*/ + * -------------------------- + */ function cleanAiMessageText(text) { - let out = String(text ?? ''); + let out = String(text ?? ''); - // 1) Strip everything before and including (handles unclosed think blocks) - // Pattern: content without opening followed by - out = out.replace(/^[\s\S]*?<\/think>/i, ''); + // 1) Strip properly wrapped / blocks only. + out = out.replace(/]*>[\s\S]*?<\/think>/gi, ''); + out = out.replace(/]*>[\s\S]*?<\/thinking>/gi, ''); - // 2) Also strip properly wrapped ... blocks - out = out.replace(/]*>[\s\S]*?<\/think>/gi, ''); - out = out.replace(/]*>[\s\S]*?<\/thinking>/gi, ''); + // 2) Strip user-configured exclude tags + // NOTE: JS \b does NOT work after CJK characters, so we use [^>]*> instead. + // Order matters: try block match first (greedy), then mop up orphan open/close tags. + const s = ensureSettings(); + const tags = s.chatExcludeTags ?? []; + for (const tag of tags) { + if (!tag) continue; + const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // First: match full block ... + const blockRe = new RegExp(`<${escaped}[^>]*>[\\s\\S]*?<\\/${escaped}>`, 'gi'); + out = out.replace(blockRe, ''); + // Then: mop up any orphan closing tags + const closeRe = new RegExp(`<\\/${escaped}>`, 'gi'); + out = out.replace(closeRe, ''); + // Finally: mop up orphan opening or self-closing tags or + const openRe = new RegExp(`<${escaped}(?:[^>]*)\\/?>`, 'gi'); + out = out.replace(openRe, ''); + } - // 3) Strip user-configured exclude tags - // NOTE: JS \b does NOT work after CJK characters, so we use [^>]*> instead. - // Order matters: try block match first (greedy), then mop up orphan open/close tags. - const s = ensureSettings(); - const tags = s.chatExcludeTags ?? []; - for (const tag of tags) { - if (!tag) continue; - const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // First: match full block ... - const blockRe = new RegExp(`<${escaped}[^>]*>[\\s\\S]*?<\\/${escaped}>`, 'gi'); - out = out.replace(blockRe, ''); - // Then: mop up any orphan closing tags - const closeRe = new RegExp(`<\\/${escaped}>`, 'gi'); - out = out.replace(closeRe, ''); - // Finally: mop up orphan opening or self-closing tags or - const openRe = new RegExp(`<${escaped}(?:[^>]*)\\/?>`, 'gi'); - out = out.replace(openRe, ''); - } - - return out.trim(); + return out.trim(); } function collectRecentChatSnippet(chat, maxMessages) { - if (!Array.isArray(chat) || chat.length === 0) return ''; + if (!Array.isArray(chat) || chat.length === 0) return ''; - // Filter: not system, not hidden, and NOT user messages (AI only) - const aiMessages = chat.filter(m => - !m?.is_system && !m?.is_user && !m?.extra?.hidden - ); + // Filter: not system, not hidden, and NOT user messages (AI only) + const aiMessages = chat.filter(m => + !m?.is_system && !m?.is_user && !m?.extra?.hidden + ); - if (!aiMessages.length) return ''; + if (!aiMessages.length) return ''; - // If maxMessages specified, only take the last N - const selected = (maxMessages && maxMessages > 0) - ? aiMessages.slice(-maxMessages) - : aiMessages; + // If maxMessages specified, only take the last N + const selected = (maxMessages && maxMessages > 0) + ? aiMessages.slice(-maxMessages) + : aiMessages; - const lines = []; - for (const m of selected) { - const name = m?.name ? `${m.name}` : 'assistant'; - const raw = (m?.mes ?? '').trim(); - if (!raw) continue; - const cleaned = cleanAiMessageText(raw); - if (!cleaned) continue; - lines.push(`[${name}] ${cleaned}`); - } + const lines = []; + for (const m of selected) { + const name = m?.name ? `${m.name}` : 'assistant'; + const raw = (m?.mes ?? '').trim(); + if (!raw) continue; + const cleaned = cleanAiMessageText(raw); + if (!cleaned) continue; + lines.push(`[${name}] ${cleaned}`); + } - if (!lines.length) return ''; - return `\n${lines.join('\n')}\n`; + if (!lines.length) return ''; + return `\n${lines.join('\n')}\n`; } function getCachedStorySummary() { @@ -356,1699 +385,1081 @@ function getCachedStorySummary() { return ''; } -/** ------------------------- +/** + * ------------------------- * Plot extraction - * --------------------------*/ + * -------------------------- + */ function extractLastNPlots(chat, n) { - if (!Array.isArray(chat) || chat.length === 0) return []; - const want = Math.max(0, Number(n) || 0); - if (!want) return []; + if (!Array.isArray(chat) || chat.length === 0) return []; + const want = Math.max(0, Number(n) || 0); + if (!want) return []; - const plots = []; - const plotRe = /]*>[\s\S]*?<\/plot>/gi; + const plots = []; + const plotRe = /]*>[\s\S]*?<\/plot>/gi; - for (let i = chat.length - 1; i >= 0; i--) { - const text = chat[i]?.mes ?? ''; - if (!text) continue; - const matches = [...text.matchAll(plotRe)]; - for (let j = matches.length - 1; j >= 0; j--) { - plots.push(matches[j][0]); - if (plots.length >= want) return plots; + for (let i = chat.length - 1; i >= 0; i--) { + const text = chat[i]?.mes ?? ''; + if (!text) continue; + const matches = [...text.matchAll(plotRe)]; + for (let j = matches.length - 1; j >= 0; j--) { + plots.push(matches[j][0]); + if (plots.length >= want) return plots; + } } - } - return plots; + return plots; } function formatPlotsBlock(plotList) { - if (!Array.isArray(plotList) || plotList.length === 0) return ''; - // plotList is [newest, ..., oldest] from extractLastNPlots - // Reverse to chronological: oldest first, newest last - const chrono = [...plotList].reverse(); - const lines = []; - chrono.forEach((p, idx) => { - lines.push(`【plot -${chrono.length - idx}】\n${p}`); - }); - return `\n${lines.join('\n\n')}\n`; + if (!Array.isArray(plotList) || plotList.length === 0) return ''; + // plotList is [newest, ..., oldest] from extractLastNPlots + // Reverse to chronological: oldest first, newest last + const chrono = [...plotList].reverse(); + const lines = []; + chrono.forEach((p, idx) => { + lines.push(`【plot -${chrono.length - idx}】\n${p}`); + }); + return `\n${lines.join('\n\n')}\n`; } -/** ------------------------- - * Vector recall — always include if present - * --------------------------*/ -function formatVectorRecallBlock(extensionPrompts) { - // ST's extensionPrompts is actually an object (key-value map), not an array. - // Most entries are ST internals — we only want actual vector recall / RAG data. - if (!extensionPrompts) return ''; - - // Known ST internal keys to skip (handled elsewhere or irrelevant) - const skipKeys = new Set([ - 'QUIET_PROMPT', 'PERSONA_DESCRIPTION', 'TEMP_USER_MESSAGE', - 'DEPTH_PROMPT', '2_floating_prompt', 'main', '__STORY_STRING__', - 'LWB_varevent_display' - ]); - - const entries = Array.isArray(extensionPrompts) - ? extensionPrompts.map((v, i) => [String(i), v]) - : Object.entries(extensionPrompts); - if (!entries.length) return ''; - - const lines = []; - for (const [key, p] of entries) { - if (!p) continue; - if (typeof key === 'string' && skipKeys.has(key)) continue; - // Skip worldbook depth entries — handled by worldbook block - if (typeof key === 'string' && /^customDepthWI/i.test(key)) continue; - // Skip 小白X (LittleWhiteBox) compressed chat/memory keys - // These start with 'ÿ' (U+00FF) or 'LWB' and contain chat history already handled elsewhere - if (typeof key === 'string' && (key.startsWith('ÿ') || key.startsWith('\u00ff') || key.startsWith('LWB'))) continue; - // Skip long hex-like keys (worldbook entries injected via ST internal mechanism) - if (typeof key === 'string' && /^\u0001/.test(key)) continue; - - // Extract text content — handle string, .value, .content, or nested content array - let textContent = ''; - if (typeof p === 'string') { - textContent = p; - } else if (typeof p?.value === 'string') { - textContent = p.value; - } else if (typeof p?.content === 'string') { - textContent = p.content; - } else if (Array.isArray(p?.content)) { - const parts = []; - for (const seg of p.content) { - if (seg?.type === 'text' && seg?.text) parts.push(seg.text); - else if (seg?.type === 'image_url') parts.push('[image_url]'); - else if (seg?.type === 'video_url') parts.push('[video_url]'); - } - textContent = parts.join(' '); - } - - const t = textContent.trim(); - // Skip short/garbage entries (e.g. "---", empty strings) - if (!t || t.length < 30) continue; - const role = typeof p?.role === 'number' - ? ['system', 'user', 'assistant'][p.role] ?? 'system' - : (p?.role ?? 'system'); - lines.push(`[${role}] ${t}`); - } - - if (!lines.length) return ''; - return `\n${lines.join('\n')}\n`; -} - -/** ------------------------- +/** + * ------------------------- * Worldbook — read via ST API (like idle-watcher) * Always read character-linked worldbooks. * Optionally include global worldbooks. * Activation: constant (blue) + keyword scan (green) only. - * --------------------------*/ + * -------------------------- + */ async function getCharacterWorldbooks() { - const ctx = getContextSafe(); - const charObj = getCurrentCharSafe(); - const worldNames = []; + const ctx = getContextSafe(); + const charObj = getCurrentCharSafe(); + const worldNames = []; - // From character object (multiple paths) - if (charObj) { - const paths = [ - charObj?.data?.extensions?.world, - charObj?.world, - charObj?.data?.character_book?.name, - ]; - for (const w of paths) { - if (w && !worldNames.includes(w)) worldNames.push(w); - } - } - - // From context - if (ctx) { - try { - const cid = ctx.characterId ?? ctx.this_chid; - const chars = ctx.characters ?? window.characters; - if (chars && cid != null) { - const c = chars[cid]; + // From character object (multiple paths) + if (charObj) { const paths = [ - c?.data?.extensions?.world, - c?.world, + charObj?.data?.extensions?.world, + charObj?.world, + charObj?.data?.character_book?.name, ]; for (const w of paths) { - if (w && !worldNames.includes(w)) worldNames.push(w); + if (w && !worldNames.includes(w)) worldNames.push(w); } - } - } catch {} + } - // ST context may expose chat-linked worldbooks via world_names + // From context + if (ctx) { + try { + const cid = ctx.characterId ?? ctx.this_chid; + const chars = ctx.characters ?? window.characters; + if (chars && cid != null) { + const c = chars[cid]; + const paths = [ + c?.data?.extensions?.world, + c?.world, + ]; + for (const w of paths) { + if (w && !worldNames.includes(w)) worldNames.push(w); + } + } + } catch { } + + // ST context may expose chat-linked worldbooks via world_names + try { + if (ctx.worldNames && Array.isArray(ctx.worldNames)) { + for (const w of ctx.worldNames) { + if (w && !worldNames.includes(w)) worldNames.push(w); + } + } + } catch { } + } + + // Fallback: try ST's selected character world info try { - if (ctx.worldNames && Array.isArray(ctx.worldNames)) { - for (const w of ctx.worldNames) { - if (w && !worldNames.includes(w)) worldNames.push(w); + const sw = window.selected_world_info; + if (typeof sw === 'string' && sw && !worldNames.includes(sw)) { + worldNames.push(sw); } - } - } catch {} - } + } catch { } - // Fallback: try ST's selected character world info - try { - const sw = window.selected_world_info; - if (typeof sw === 'string' && sw && !worldNames.includes(sw)) { - worldNames.push(sw); - } - } catch {} + // Fallback: try reading from chat metadata + try { + const chat = ctx?.chat ?? []; + if (chat.length > 0 && chat[0]?.extra?.world) { + const w = chat[0].extra.world; + if (!worldNames.includes(w)) worldNames.push(w); + } + } catch { } - // Fallback: try reading from chat metadata - try { - const chat = ctx?.chat ?? []; - if (chat.length > 0 && chat[0]?.extra?.world) { - const w = chat[0].extra.world; - if (!worldNames.includes(w)) worldNames.push(w); - } - } catch {} - - console.log('[EnaPlanner] Character worldbook names found:', worldNames); - return worldNames.filter(Boolean); + console.log('[EnaPlanner] Character worldbook names found:', worldNames); + return worldNames.filter(Boolean); } async function getGlobalWorldbooks() { - // Try to get the list of currently active global worldbooks - try { - // ST stores active worldbooks in world_info settings - const ctx = getContextSafe(); - if (ctx?.world_info?.globalSelect) { - return Array.isArray(ctx.world_info.globalSelect) ? ctx.world_info.globalSelect : []; - } - } catch {} + // Try to get the list of currently active global worldbooks + try { + // ST stores active worldbooks in world_info settings + const ctx = getContextSafe(); + if (ctx?.world_info?.globalSelect) { + return Array.isArray(ctx.world_info.globalSelect) ? ctx.world_info.globalSelect : []; + } + } catch { } - // Fallback: try window.selected_world_info - try { - if (window.selected_world_info && Array.isArray(window.selected_world_info)) { - return window.selected_world_info; - } - } catch {} + // Fallback: try window.selected_world_info + try { + if (window.selected_world_info && Array.isArray(window.selected_world_info)) { + return window.selected_world_info; + } + } catch { } - return []; + return []; } async function getWorldbookData(worldName) { - if (!worldName) return null; - try { - const response = await fetch('/api/worldinfo/get', { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify({ name: worldName }), - }); - if (response.ok) { - const data = await response.json(); - // ST returns { entries: {...} } or { entries: [...] } - let entries = data?.entries; - if (entries && !Array.isArray(entries)) { - entries = Object.values(entries); - } - return { name: worldName, entries: entries || [] }; + if (!worldName) return null; + try { + const response = await fetch('/api/worldinfo/get', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name: worldName }), + }); + if (response.ok) { + const data = await response.json(); + // ST returns { entries: {...} } or { entries: [...] } + let entries = data?.entries; + if (entries && !Array.isArray(entries)) { + entries = Object.values(entries); + } + return { name: worldName, entries: entries || [] }; + } + } catch (e) { + console.warn(`[EnaPlanner] Failed to load worldbook "${worldName}":`, e); } - } catch (e) { - console.warn(`[EnaPlanner] Failed to load worldbook "${worldName}":`, e); - } - return null; + return null; } function keywordPresent(text, kw) { - if (!kw) return false; - return text.toLowerCase().includes(kw.toLowerCase()); + if (!kw) return false; + return text.toLowerCase().includes(kw.toLowerCase()); } function matchSelective(entry, scanText) { - const keys = Array.isArray(entry?.key) ? entry.key.filter(Boolean) : []; - const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : []; + const keys = Array.isArray(entry?.key) ? entry.key.filter(Boolean) : []; + const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : []; - const total = keys.length; - const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0); + const total = keys.length; + const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0); - let ok = false; - const logic = entry?.selectiveLogic ?? 0; - if (logic === 0) ok = (total === 0) ? true : hit > 0; // and_any - else if (logic === 1) ok = (total === 0) ? true : hit < total; // not_all - else if (logic === 2) ok = (total === 0) ? true : hit === 0; // not_any - else if (logic === 3) ok = (total === 0) ? true : hit === total; // and_all + let ok = false; + const logic = entry?.selectiveLogic ?? 0; + if (logic === 0) ok = (total === 0) ? true : hit > 0; // and_any + else if (logic === 1) ok = (total === 0) ? true : hit < total; // not_all + else if (logic === 2) ok = (total === 0) ? true : hit === 0; // not_any + else if (logic === 3) ok = (total === 0) ? true : hit === total; // and_all - if (!ok) return false; + if (!ok) return false; - if (keys2.length) { - const hit2 = keys2.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0); - if (hit2 <= 0) return false; - } - return true; + if (keys2.length) { + const hit2 = keys2.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0); + if (hit2 <= 0) return false; + } + return true; } function sortWorldEntries(entries) { - // Sort to mimic ST insertion order within our worldbook block. - // Position priority: 0 (before char def) → 1 (after char def) → 4 (system depth) - // Within pos=4: depth descending (bigger depth = further from chat = earlier) - // Same position+depth: order ascending (higher order = closer to chat_history = later) - const posPriority = { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4 }; - return [...entries].sort((a, b) => { - const pa = posPriority[Number(a?.position ?? 0)] ?? 99; - const pb = posPriority[Number(b?.position ?? 0)] ?? 99; - if (pa !== pb) return pa - pb; - // For same position (especially pos=4): bigger depth = earlier - const da = Number(a?.depth ?? 0); - const db = Number(b?.depth ?? 0); - if (da !== db) return db - da; - // Same position+depth: order ascending (smaller order first, bigger order later) - const oa = Number(a?.order ?? 0); - const ob = Number(b?.order ?? 0); - return oa - ob; - }); + // Sort to mimic ST insertion order within our worldbook block. + // Position priority: 0 (before char def) → 1 (after char def) → 4 (system depth) + // Within pos=4: depth descending (bigger depth = further from chat = earlier) + // Same position+depth: order ascending (higher order = closer to chat_history = later) + const posPriority = { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4 }; + return [...entries].sort((a, b) => { + const pa = posPriority[Number(a?.position ?? 0)] ?? 99; + const pb = posPriority[Number(b?.position ?? 0)] ?? 99; + if (pa !== pb) return pa - pb; + // For same position (especially pos=4): bigger depth = earlier + const da = Number(a?.depth ?? 0); + const db = Number(b?.depth ?? 0); + if (da !== db) return db - da; + // Same position+depth: order ascending (smaller order first, bigger order later) + const oa = Number(a?.order ?? 0); + const ob = Number(b?.order ?? 0); + return oa - ob; + }); } async function buildWorldbookBlock(scanText) { - const s = ensureSettings(); + const s = ensureSettings(); - // 1. Always get character-linked worldbooks - const charWorldNames = await getCharacterWorldbooks(); + // 1. Always get character-linked worldbooks + const charWorldNames = await getCharacterWorldbooks(); - // 2. Optionally get global worldbooks - let globalWorldNames = []; - if (s.includeGlobalWorldbooks) { - globalWorldNames = await getGlobalWorldbooks(); - } - - // Deduplicate - const allWorldNames = [...new Set([...charWorldNames, ...globalWorldNames])]; - - if (!allWorldNames.length) { - console.log('[EnaPlanner] No worldbooks to load'); - return ''; - } - - console.log('[EnaPlanner] Loading worldbooks:', allWorldNames); - - // Fetch all worldbook data - const worldbookResults = await Promise.all(allWorldNames.map(name => getWorldbookData(name))); - const allEntries = []; - - for (const wb of worldbookResults) { - if (!wb || !wb.entries) continue; - for (const entry of wb.entries) { - if (!entry) continue; - allEntries.push({ ...entry, _worldName: wb.name }); - } - } - - // Filter: not disabled - let entries = allEntries.filter(e => !e?.disable && !e?.disabled); - - // Filter: exclude entries whose name contains any of the configured exclude patterns - const nameExcludes = s.worldbookExcludeNames ?? ['mvu_update']; - entries = entries.filter(e => { - const comment = String(e?.comment || e?.name || e?.title || ''); - for (const pat of nameExcludes) { - if (pat && comment.includes(pat)) return false; - } - return true; - }); - - // Filter: exclude position=4 if configured - if (s.excludeWorldbookPosition4) { - entries = entries.filter(e => Number(e?.position) !== 4); - } - - if (!entries.length) return ''; - - // Activation: constant (blue) + keyword scan (green) only - const active = []; - for (const e of entries) { - // Blue light: constant entries always included - if (e?.constant) { - active.push(e); - continue; - } - // Green light: keyword-triggered entries - if (matchSelective(e, scanText)) { - active.push(e); - continue; - } - } - - if (!active.length) return ''; - - // Build EJS context for rendering worldbook templates - const ejsCtx = buildEjsContext(); - - const sorted = sortWorldEntries(active); - const parts = []; - for (const e of sorted) { - const comment = e?.comment || e?.name || e?.title || ''; - const head = `【WorldBook:${e._worldName}】${comment ? ' ' + comment : ''}`.trim(); - let body = String(e?.content ?? '').trim(); - if (!body) continue; - - // Try EJS rendering if the entry contains EJS tags - if (body.includes('<%')) { - body = renderEjsTemplate(body, ejsCtx); + // 2. Optionally get global worldbooks + let globalWorldNames = []; + if (s.includeGlobalWorldbooks) { + globalWorldNames = await getGlobalWorldbooks(); } - parts.push(`${head}\n${body}`); - } + // Deduplicate + const allWorldNames = [...new Set([...charWorldNames, ...globalWorldNames])]; - if (!parts.length) return ''; - return `\n${parts.join('\n\n---\n\n')}\n`; + if (!allWorldNames.length) { + console.log('[EnaPlanner] No worldbooks to load'); + return ''; + } + + console.log('[EnaPlanner] Loading worldbooks:', allWorldNames); + + // Fetch all worldbook data + const worldbookResults = await Promise.all(allWorldNames.map(name => getWorldbookData(name))); + const allEntries = []; + + for (const wb of worldbookResults) { + if (!wb || !wb.entries) continue; + for (const entry of wb.entries) { + if (!entry) continue; + allEntries.push({ ...entry, _worldName: wb.name }); + } + } + + // Filter: not disabled + let entries = allEntries.filter(e => !e?.disable && !e?.disabled); + + // Filter: exclude entries whose name contains any of the configured exclude patterns + const nameExcludes = s.worldbookExcludeNames ?? ['mvu_update']; + entries = entries.filter(e => { + const comment = String(e?.comment || e?.name || e?.title || ''); + for (const pat of nameExcludes) { + if (pat && comment.includes(pat)) return false; + } + return true; + }); + + // Filter: exclude position=4 if configured + if (s.excludeWorldbookPosition4) { + entries = entries.filter(e => Number(e?.position) !== 4); + } + + if (!entries.length) return ''; + + // Activation: constant (blue) + keyword scan (green) only + const active = []; + for (const e of entries) { + // Blue light: constant entries always included + if (e?.constant) { + active.push(e); + continue; + } + // Green light: keyword-triggered entries + if (matchSelective(e, scanText)) { + active.push(e); + continue; + } + } + + if (!active.length) return ''; + + // Build EJS context for rendering worldbook templates + const ejsCtx = buildEjsContext(); + + const sorted = sortWorldEntries(active); + const parts = []; + for (const e of sorted) { + const comment = e?.comment || e?.name || e?.title || ''; + const head = `【WorldBook:${e._worldName}】${comment ? ' ' + comment : ''}`.trim(); + let body = String(e?.content ?? '').trim(); + if (!body) continue; + + // Try EJS rendering if the entry contains EJS tags + if (body.includes('<%')) { + body = renderEjsTemplate(body, ejsCtx); + } + + parts.push(`${head}\n${body}`); + } + + if (!parts.length) return ''; + return `\n${parts.join('\n\n---\n\n')}\n`; } -/** ------------------------- +/** + * ------------------------- * EJS rendering for worldbook entries - * --------------------------*/ + * -------------------------- + */ function getChatVariables() { - // Try multiple paths to get ST chat variables - try { - const ctx = getContextSafe(); - if (ctx?.chatMetadata?.variables) return ctx.chatMetadata.variables; - } catch {} - try { - if (window.chat_metadata?.variables) return window.chat_metadata.variables; - } catch {} - try { - const ctx = getContextSafe(); - if (ctx?.chat_metadata?.variables) return ctx.chat_metadata.variables; - } catch {} - return {}; + // Try multiple paths to get ST chat variables + try { + const ctx = getContextSafe(); + if (ctx?.chatMetadata?.variables) return ctx.chatMetadata.variables; + } catch { } + try { + if (window.chat_metadata?.variables) return window.chat_metadata.variables; + } catch { } + try { + const ctx = getContextSafe(); + if (ctx?.chat_metadata?.variables) return ctx.chat_metadata.variables; + } catch { } + return {}; } function buildEjsContext() { - const vars = getChatVariables(); + const vars = getChatVariables(); - // getvar: read a chat variable (supports dot-path for nested objects) - function getvar(name) { - if (!name) return ''; - let val; - if (vars[name] !== undefined) { - val = vars[name]; - } else { - const parts = String(name).split('.'); - let cur = vars; - for (const p of parts) { - if (cur == null || typeof cur !== 'object') return ''; - cur = cur[p]; - } - val = cur ?? ''; + // getvar: read a chat variable (supports dot-path for nested objects) + function getvar(name) { + if (!name) return ''; + let val; + if (vars[name] !== undefined) { + val = vars[name]; + } else { + const parts = String(name).split('.'); + let cur = vars; + for (const p of parts) { + if (cur == null || typeof cur !== 'object') return ''; + cur = cur[p]; + } + val = cur ?? ''; + } + // 字符串布尔值转为真正的布尔值 + if (val === 'false' || val === 'False' || val === 'FALSE') return false; + if (val === 'true' || val === 'True' || val === 'TRUE') return true; + return val; } - // 字符串布尔值转为真正的布尔值 - if (val === 'false' || val === 'False' || val === 'FALSE') return false; - if (val === 'true' || val === 'True' || val === 'TRUE') return true; - return val; - } - // setvar: write a chat variable (no-op for our purposes, just to avoid errors) - function setvar(name, value) { - if (name) vars[name] = value; - return value; - } + // setvar: write a chat variable (no-op for our purposes, just to avoid errors) + function setvar(name, value) { + if (name) vars[name] = value; + return value; + } - // Compute common derived values that entries might reference - const fire = Number(getvar('stat_data.蒂娜.火')) || 0; - const ice = Number(getvar('stat_data.蒂娜.冰')) || 0; - const dark = Number(getvar('stat_data.蒂娜.暗')) || 0; - const light = Number(getvar('stat_data.蒂娜.光')) || 0; - const maxAttrValue = Math.max(fire, ice, dark, light); - - return { - getvar, setvar, - fire, ice, dark, light, - maxAttrValue, - Number, Math, JSON, String, Array, Object, parseInt, parseFloat, - console: { log: () => {}, warn: () => {}, error: () => {} }, - }; + return { + getvar, setvar, + Number, Math, JSON, String, Array, Object, parseInt, parseFloat, + console: { log: () => { }, warn: () => { }, error: () => { } }, + }; } function renderEjsTemplate(template, ctx) { - // Try window.ejs first (ST loads this library) - if (window.ejs?.render) { - try { - return window.ejs.render(template, ctx, { async: false }); - } catch (e) { - console.warn('[EnaPlanner] EJS render failed, trying fallback:', e?.message); + // Try window.ejs first (ST loads this library) + if (window.ejs?.render) { + try { + return window.ejs.render(template, ctx, { async: false }); + } catch (e) { + console.warn('[EnaPlanner] EJS render failed, trying fallback:', e?.message); + } } - } - // Fallback: manual <%_ ... _%> / <%= ... %> processing - try { - return evalEjsFallback(template, ctx); - } catch (e) { - console.warn('[EnaPlanner] EJS fallback failed:', e?.message); - return template; // Return raw if all fails - } + // Safe degradation when ejs is not available. + console.warn('[EnaPlanner] window.ejs not available, skipping EJS rendering. Template returned as-is.'); + return template; } -function evalEjsFallback(template, ctx) { - // Build a function from the EJS template - let fnBody = 'let __out = "";\n'; - - // Create local variable declarations from context - for (const [k, v] of Object.entries(ctx)) { - if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k)) { - fnBody += `const ${k} = __ctx['${k}'];\n`; - } - } - - // Also add _ object with set method for _.set() patterns - fnBody += 'const _ = { set: function() {} };\n'; - - // Parse EJS template - let pos = 0; - const src = template; - while (pos < src.length) { - const tagStart = src.indexOf('<%', pos); - if (tagStart === -1) { - // Rest is plain text - fnBody += `__out += ${JSON.stringify(src.slice(pos))};\n`; - break; - } - - // Text before tag - if (tagStart > pos) { - fnBody += `__out += ${JSON.stringify(src.slice(pos, tagStart))};\n`; - } - - const tagEnd = src.indexOf('%>', tagStart); - if (tagEnd === -1) { - fnBody += `__out += ${JSON.stringify(src.slice(tagStart))};\n`; - break; - } - - let inner = src.slice(tagStart + 2, tagEnd); - // Strip whitespace control chars - if (inner.startsWith('_')) inner = inner.slice(1); - if (inner.endsWith('_')) inner = inner.slice(0, -1); - - if (inner.startsWith('=')) { - // Output expression - fnBody += `__out += String(${inner.slice(1).trim()});\n`; - } else if (inner.startsWith('-')) { - // HTML-escaped output - fnBody += `__out += String(${inner.slice(1).trim()});\n`; - } else { - // Code block - fnBody += inner.trim() + '\n'; - } - - pos = tagEnd + 2; - } - - fnBody += 'return __out;'; - - const fn = new Function('__ctx', fnBody); - return fn(ctx); -} - -/** ------------------------- +/** + * ------------------------- * Template rendering helpers - * --------------------------*/ + * -------------------------- + */ async function prepareEjsEnv() { - try { - const et = window.EjsTemplate; - if (!et) return null; - const fn = et.prepareContext || et.preparecontext; - if (typeof fn !== 'function') return null; - return await fn.call(et, {}); - } catch { return null; } + try { + const et = window.EjsTemplate; + if (!et) return null; + const fn = et.prepareContext || et.preparecontext; + if (typeof fn !== 'function') return null; + return await fn.call(et, {}); + } catch { return null; } } async function evalEjsIfPossible(text, env) { - try { - const et = window.EjsTemplate; - if (!et || !env) return text; - const fn = et.evalTemplate || et.evaltemplate; - if (typeof fn !== 'function') return text; - return await fn.call(et, text, env); - } catch { return text; } + try { + const et = window.EjsTemplate; + if (!et || !env) return text; + const fn = et.evalTemplate || et.evaltemplate; + if (typeof fn !== 'function') return text; + return await fn.call(et, text, env); + } catch { return text; } } function substituteMacrosViaST(text) { - try { return substituteParamsExtended(text); } catch { return text; } + try { return substituteParamsExtended(text); } catch { return text; } } function deepGet(obj, path) { - if (!obj || !path) return undefined; - const parts = path.split('.').filter(Boolean); - let cur = obj; - for (const p of parts) { - if (cur == null) return undefined; - cur = cur[p]; - } - return cur; + if (!obj || !path) return undefined; + const parts = path.split('.').filter(Boolean); + let cur = obj; + for (const p of parts) { + if (cur == null) return undefined; + cur = cur[p]; + } + return cur; } function resolveGetMessageVariableMacros(text, messageVars) { - return text.replace(/{{\s*get_message_variable::([^}]+)\s*}}/g, (_, rawPath) => { - const path = String(rawPath || '').trim(); - if (!path) return ''; - return safeStringify(deepGet(messageVars, path)); - }); + return text.replace(/{{\s*get_message_variable::([^}]+)\s*}}/g, (_, rawPath) => { + const path = String(rawPath || '').trim(); + if (!path) return ''; + return safeStringify(deepGet(messageVars, path)); + }); } function getLatestMessageVarTable() { - try { - if (window.Mvu?.getMvuData) { - return window.Mvu.getMvuData({ type: 'message', message_id: 'latest' }); - } - } catch {} - try { - const getVars = window.TavernHelper?.getVariables || window.Mvu?.getMvuData; - if (typeof getVars === 'function') { - return getVars({ type: 'message', message_id: 'latest' }); - } - } catch {} - return {}; + try { + if (window.Mvu?.getMvuData) { + return window.Mvu.getMvuData({ type: 'message', message_id: 'latest' }); + } + } catch { } + try { + const getVars = window.TavernHelper?.getVariables || window.Mvu?.getMvuData; + if (typeof getVars === 'function') { + return getVars({ type: 'message', message_id: 'latest' }); + } + } catch { } + return {}; } async function renderTemplateAll(text, env, messageVars) { - let out = String(text ?? ''); - out = await evalEjsIfPossible(out, env); - out = substituteMacrosViaST(out); - out = resolveGetMessageVariableMacros(out, messageVars); - return out; + let out = String(text ?? ''); + out = await evalEjsIfPossible(out, env); + out = substituteMacrosViaST(out); + out = resolveGetMessageVariableMacros(out, messageVars); + return out; } -/** ------------------------- +/** + * ------------------------- * Planner response filtering - * --------------------------*/ + * -------------------------- + */ function stripThinkBlocks(text) { - let out = String(text ?? ''); - out = out.replace(/]*>[\s\S]*?<\/think>/gi, ''); - out = out.replace(/]*>[\s\S]*?<\/thinking>/gi, ''); - return out.trim(); + let out = String(text ?? ''); + out = out.replace(/]*>[\s\S]*?<\/think>/gi, ''); + out = out.replace(/]*>[\s\S]*?<\/thinking>/gi, ''); + return out.trim(); } -function extractPlotAndNoteInOrder(text) { - const src = String(text ?? ''); - const blocks = []; - const re = /<(plot|note)\b[^>]*>[\s\S]*?<\/\1>/gi; - let m; - while ((m = re.exec(src)) !== null) { - blocks.push(m[0]); - } - return blocks.join('\n\n').trim(); +function extractSelectedBlocksInOrder(text, tagNames) { + const names = normalizeResponseKeepTags(tagNames); + if (!Array.isArray(names) || names.length === 0) return ''; + const src = String(text ?? ''); + const blocks = []; + const escapedNames = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const re = new RegExp(`<(${escapedNames.join('|')})\\b[^>]*>[\\s\\S]*?<\\/\\1>`, 'gi'); + let m; + while ((m = re.exec(src)) !== null) { + blocks.push(m[0]); + } + return blocks.join('\n\n').trim(); } function filterPlannerForInput(rawFull) { - const noThink = stripThinkBlocks(rawFull); - const onlyPN = extractPlotAndNoteInOrder(noThink); - if (onlyPN) return onlyPN; - return noThink; + const noThink = stripThinkBlocks(rawFull); + const tags = ensureSettings().responseKeepTags; + const selected = extractSelectedBlocksInOrder(noThink, tags); + if (selected) return selected; + return noThink; } -/** ------------------------- +function filterPlannerPreview(rawPartial) { + return stripThinkBlocks(rawPartial); +} + +/** + * ------------------------- * Planner API calls - * --------------------------*/ -async function fetchModels() { - const s = ensureSettings(); - if (!s.api.baseUrl) throw new Error('请先填写 API URL'); - if (!s.api.apiKey) throw new Error('请先填写 API KEY'); + * -------------------------- + */ +async function callPlanner(messages, options = {}) { + const s = ensureSettings(); + if (!s.api.baseUrl) throw new Error('未配置 API URL'); + if (!s.api.apiKey) throw new Error('未配置 API KEY'); + if (!s.api.model) throw new Error('未选择模型'); - const url = buildUrl('/models'); - const res = await fetch(url, { - method: 'GET', - headers: { - ...getRequestHeaders(), - Authorization: `Bearer ${s.api.apiKey}` + const url = buildUrl('/chat/completions'); + + const body = { + model: s.api.model, + messages, + stream: !!s.api.stream + }; + + const t = Number(s.api.temperature); + if (!Number.isNaN(t)) body.temperature = t; + const tp = Number(s.api.top_p); + if (!Number.isNaN(tp)) body.top_p = tp; + const tk = Number(s.api.top_k); + if (!Number.isNaN(tk) && tk > 0) body.top_k = tk; + const pp = s.api.presence_penalty === '' ? null : Number(s.api.presence_penalty); + if (pp != null && !Number.isNaN(pp)) body.presence_penalty = pp; + const fp = s.api.frequency_penalty === '' ? null : Number(s.api.frequency_penalty); + if (fp != null && !Number.isNaN(fp)) body.frequency_penalty = fp; + const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens); + if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), PLANNER_REQUEST_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + ...getRequestHeaders(), + Authorization: `Bearer ${s.api.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500)); + } + + if (!s.api.stream) { + const data = await res.json(); + const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? ''); + if (text) options?.onDelta?.(text, text); + return text; + } + + // SSE stream + const reader = res.body.getReader(); + const decoder = new TextDecoder('utf-8'); + let buf = ''; + let full = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const chunks = buf.split('\n\n'); + buf = chunks.pop() ?? ''; + + for (const ch of chunks) { + const lines = ch.split('\n').map(x => x.trim()).filter(Boolean); + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (payload === '[DONE]') continue; + try { + const j = JSON.parse(payload); + const delta = j?.choices?.[0]?.delta; + const piece = delta?.content ?? delta?.text ?? ''; + if (piece) { + full += piece; + options?.onDelta?.(piece, full); + } + } catch { } + } + } + } + return full; + } catch (err) { + if (controller.signal.aborted || err?.name === 'AbortError') { + throw new Error(`规划请求超时(>${Math.floor(PLANNER_REQUEST_TIMEOUT_MS / 1000)}s)`); + } + throw err; + } finally { + clearTimeout(timeoutId); } - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300)); - } - - const data = await res.json(); - const list = Array.isArray(data?.data) ? data.data : []; - return list.map(x => x?.id).filter(Boolean); } -async function callPlanner(messages) { - const s = ensureSettings(); - if (!s.api.baseUrl) throw new Error('未配置 API URL'); - if (!s.api.apiKey) throw new Error('未配置 API KEY'); - if (!s.api.model) throw new Error('未选择模型'); - - const url = buildUrl('/chat/completions'); - - const body = { - model: s.api.model, - messages, - stream: !!s.api.stream - }; - - const t = Number(s.api.temperature); - if (!Number.isNaN(t)) body.temperature = t; - const tp = Number(s.api.top_p); - if (!Number.isNaN(tp)) body.top_p = tp; - const tk = Number(s.api.top_k); - if (!Number.isNaN(tk) && tk > 0) body.top_k = tk; - const pp = s.api.presence_penalty === '' ? null : Number(s.api.presence_penalty); - if (pp != null && !Number.isNaN(pp)) body.presence_penalty = pp; - const fp = s.api.frequency_penalty === '' ? null : Number(s.api.frequency_penalty); - if (fp != null && !Number.isNaN(fp)) body.frequency_penalty = fp; - const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens); - if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt; - - const res = await fetch(url, { - method: 'POST', - headers: { - ...getRequestHeaders(), - Authorization: `Bearer ${s.api.apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500)); - } - - if (!s.api.stream) { +async function fetchModelsForUi() { + const s = ensureSettings(); + if (!s.api.baseUrl) throw new Error('请先填写 API URL'); + if (!s.api.apiKey) throw new Error('请先填写 API KEY'); + const url = buildUrl('/models'); + const res = await fetch(url, { + method: 'GET', + headers: { + ...getRequestHeaders(), + Authorization: `Bearer ${s.api.apiKey}` + } + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300)); + } const data = await res.json(); - return String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? ''); - } - - // SSE stream - const reader = res.body.getReader(); - const decoder = new TextDecoder('utf-8'); - let buf = ''; - let full = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - const chunks = buf.split('\n\n'); - buf = chunks.pop() ?? ''; - - for (const ch of chunks) { - const lines = ch.split('\n').map(x => x.trim()).filter(Boolean); - for (const line of lines) { - if (!line.startsWith('data:')) continue; - const payload = line.slice(5).trim(); - if (payload === '[DONE]') continue; - try { - const j = JSON.parse(payload); - const delta = j?.choices?.[0]?.delta; - const piece = delta?.content ?? delta?.text ?? ''; - if (piece) full += piece; - } catch {} - } - } - } - return full; + const list = Array.isArray(data?.data) ? data.data : []; + return list.map(x => x?.id).filter(Boolean); } -/** ------------------------- - * Build planner messages - * --------------------------*/ -function getPromptBlocksByRole(role) { - const s = ensureSettings(); - return (s.promptBlocks || []).filter(b => b?.role === role && String(b?.content ?? '').trim()); -} - -async function buildPlannerMessages(rawUserInput) { - const s = ensureSettings(); - const ctx = getContextSafe(); - const chat = ctx?.chat ?? window.SillyTavern?.chat ?? []; - const extPrompts = ctx?.extensionPrompts ?? {}; - const charObj = getCurrentCharSafe(); - const env = await prepareEjsEnv(); - const messageVars = getLatestMessageVarTable(); - - const enaSystemBlocks = getPromptBlocksByRole('system'); - const enaAssistantBlocks = getPromptBlocksByRole('assistant'); - const enaUserBlocks = getPromptBlocksByRole('user'); - - const charBlockRaw = formatCharCardBlock(charObj); - - // --- Story summary (cached from previous generation via interceptor) --- - const cachedSummary = getCachedStorySummary(); - - // --- Chat history: last 2 AI messages (floors N-1 & N-3) --- - // Two messages instead of one to avoid cross-device cache miss: - // story_summary cache is captured during main AI generation, so if - // user switches device and triggers Ena before a new generation, - // having N-3 as backup context prevents a gap. - const recentChatRaw = collectRecentChatSnippet(chat, 2); - - const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount)); - const vectorRaw = formatVectorRecallBlock(extPrompts); - - // Build scanText for worldbook keyword activation - const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n'); - - const worldbookRaw = await buildWorldbookBlock(scanText); - - // Render templates/macros - const charBlock = await renderTemplateAll(charBlockRaw, env, messageVars); - const recentChat = await renderTemplateAll(recentChatRaw, env, messageVars); - const plots = await renderTemplateAll(plotsRaw, env, messageVars); - const vector = await renderTemplateAll(vectorRaw, env, messageVars); - const storySummary = cachedSummary.trim().length > 30 ? await renderTemplateAll(cachedSummary, env, messageVars) : ''; - const worldbook = await renderTemplateAll(worldbookRaw, env, messageVars); - const userInput = await renderTemplateAll(rawUserInput, env, messageVars); - - const messages = []; - - // 1) Ena system prompts - for (const b of enaSystemBlocks) { - const content = await renderTemplateAll(b.content, env, messageVars); - messages.push({ role: 'system', content }); - } - - // 2) Character card - if (String(charBlock).trim()) messages.push({ role: 'system', content: charBlock }); - - // 3) Worldbook - if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook }); - - // 3.5) Cached story summary (小白X 剧情记忆 from previous turn) - if (storySummary.trim()) { - messages.push({ role: 'system', content: `\n${storySummary}\n` }); - } - - // 4) Chat history (last 2 AI responses — floors N-1 & N-3) - if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat }); - - // 5) Vector recall - if (String(vector).trim()) messages.push({ role: 'system', content: vector }); - - // 6) Previous plots - if (String(plots).trim()) messages.push({ role: 'system', content: plots }); - - // 7) User input (with friendly framing) - const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`; - messages.push({ role: 'user', content: userMsgContent }); - - // Extra user blocks before user message - for (const b of enaUserBlocks) { - const content = await renderTemplateAll(b.content, env, messageVars); - messages.splice(Math.max(0, messages.length - 1), 0, { role: 'system', content: `【extra-user-block】\n${content}` }); - } - - // 8) Assistant blocks - for (const b of enaAssistantBlocks) { - const content = await renderTemplateAll(b.content, env, messageVars); - messages.push({ role: 'assistant', content }); - } - - return { messages, meta: { charBlockRaw, worldbookRaw, recentChatRaw, vectorRaw, cachedSummaryLen: cachedSummary.length, plotsRaw } }; -} - -/** ------------------------- - * Logs UI — Issue #3: proper formatting - * --------------------------*/ -function createLogModalHTML() { - return ` - -
-
-
-
Ena Planner Logs
-
- - - -
-
-
-
-
`; -} - -function renderLogs() { - const body = document.getElementById('ep_log_body'); - if (!body) return; - - if (!state.logs.length) { - body.innerHTML = `
暂无日志(发送一次消息后就会记录)。
`; - return; - } - - const html = state.logs.map((log, idx) => { - const t = log.time ?? ''; - const title = log.ok ? 'OK' : 'ERROR'; - const model = log.model ?? ''; - const err = log.error ?? ''; - - // Format request messages for readable display - const reqDisplay = (log.requestMessages ?? []).map((m, i) => { - return `--- Message #${i + 1} [${m.role}] ---\n${m.content ?? '(empty)'}`; - }).join('\n\n'); - - return ` -
-
- #${idx + 1} · ${title} · ${t} · ${model} - ${log.ok ? '✅' : '❌'} -
- ${err ? `
${escapeHtml(err)}
` : ''} - -
- 发出去的 messages(完整) -
${escapeHtml(reqDisplay)}
-
- -
- 规划 AI 原始完整回复(含 <think>) -
${escapeHtml(String(log.rawReply ?? ''))}
-
- -
- 写回输入框的版本(已剔除 think,只保留 plot+note) -
${escapeHtml(String(log.filteredReply ?? ''))}
-
-
`; - }).join(''); - - body.innerHTML = html; -} - -function openLogModal() { - const m = document.getElementById('ep_log_modal'); - if (!m) return; - m.classList.add('open'); - renderLogs(); -} -function closeLogModal() { - const m = document.getElementById('ep_log_modal'); - if (!m) return; - m.classList.remove('open'); -} - -/** ------------------------- - * Settings UI — Issue #1: use inline-drawer for collapsible - * --------------------------*/ -function createSettingsHTML() { - const s = ensureSettings(); - const channel = s.api.channel; - - return ` - -
-
- Ena Planner - - - ${s.enabled ? 'Enabled' : 'Disabled'} - -
- -
-
总览
-
API
-
提示词
-
调试
-
- - -
-
-
- - -
开启后:你点发送/回车,会先走"规划模型",把规划结果写回输入框再发送。
-
-
- - -
防止"原始+规划文本"再次被拦截规划。
-
-
- -
- -
-
- - -
角色绑定的世界书总是会读取。这里选择是否额外包含全局世界书。
-
-
- - -
-
- -
-
- - -
条目名称/备注包含这些字符串的条目会被排除。
-
-
- -
-
- - -
-
- -
- 自动行为说明:
- · 聊天片段:自动读取所有未隐藏的 AI 回复(不含用户输入)
- · 自动剔除 <think> 以前的内容(含未包裹的思考段落)
- · 角色卡字段(desc/personality/scenario):有就全部加入
- · 向量召回(extensionPrompts):有就自动加入
- · 世界书激活:常驻(蓝灯)+ 关键词触发(绿灯) -
- -
- -
-
- - -
这些 XML 标签及其内容会从聊天历史中剔除。自闭合标签(如 <Tag/>)也会被移除。
-
-
- -
- - -
-
- - -
-
-
- - -
影响默认前缀:OpenAI/Claude → /v1,Gemini → /v1beta
-
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
新增多条提示词块,选择 role(system/user/assistant)。系统块放最前面;assistant 块放最后。
- -
- - - - -
- -
-
- - -
-
- - -
-
- 工作原理:
- · 规划时会锁定发送按钮
- · Log 静默记录,只有出错才弹提示
- · 写回版本:剔除 <think>,只保留 <plot>+<note>
- · 前文自动剔除 <think> 以前内容和排除标签内容 -
-
- - -
- -
-
`; -} - -function renderPromptDesigner() { - const s = ensureSettings(); - const list = document.getElementById('ep_prompt_list'); - if (!list) return; - - const blocks = s.promptBlocks || []; - const rows = blocks.map((b, idx) => { - const role = b.role || 'system'; - return ` -
-
-
- - -
-
- - - -
-
- -
`; - }).join(''); - - list.innerHTML = rows || `
暂无提示词块
`; -} - -function bindSettingsUI() { - const settingsEl = document.getElementById('ena_planner_panel'); - if (!settingsEl) return; - - // Tabs - settingsEl.querySelectorAll('.ep-tab').forEach(tab => { - tab.addEventListener('click', () => { - settingsEl.querySelectorAll('.ep-tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - const id = tab.getAttribute('data-ep-tab'); - settingsEl.querySelectorAll('.ep-panel').forEach(p => p.classList.remove('active')); - const panel = settingsEl.querySelector(`.ep-panel[data-ep-panel="${id}"]`); - if (panel) panel.classList.add('active'); - if (id === 'prompt') renderPromptDesigner(); - }); - }); - - function save() { saveSettingsDebounced(); } - - // General - document.getElementById('ep_enabled')?.addEventListener('change', (e) => { - const s = ensureSettings(); - s.enabled = e.target.value === 'true'; - save(); - toastInfo(`Ena Planner: ${s.enabled ? '启用' : '关闭'}`); - // Update badge - const badge = document.querySelector('.ep-badge-inline'); - if (badge) { - badge.className = `ep-badge-inline ${s.enabled ? 'ok' : 'warn'}`; - badge.querySelector('span:last-child').textContent = s.enabled ? 'Enabled' : 'Disabled'; - } - }); - - document.getElementById('ep_skip_plot')?.addEventListener('change', (e) => { - ensureSettings().skipIfPlotPresent = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_include_global_wb')?.addEventListener('change', (e) => { - ensureSettings().includeGlobalWorldbooks = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_wb_pos4')?.addEventListener('change', (e) => { - ensureSettings().excludeWorldbookPosition4 = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_wb_exclude_names')?.addEventListener('change', (e) => { - const raw = e.target.value ?? ''; - ensureSettings().worldbookExcludeNames = raw.split(',').map(t => t.trim()).filter(Boolean); - save(); - }); - - document.getElementById('ep_plot_n')?.addEventListener('change', (e) => { - ensureSettings().plotCount = Number(e.target.value) || 0; save(); - }); - - document.getElementById('ep_exclude_tags')?.addEventListener('change', (e) => { - const raw = e.target.value ?? ''; - ensureSettings().chatExcludeTags = raw.split(',').map(t => t.trim()).filter(Boolean); - save(); - }); - - // Logs — unified pointer handler for desktop + mobile - const logBtn = document.getElementById('ep_open_logs'); - if (logBtn) { - _addUniversalTap(logBtn, () => openLogModal()); - } - - document.getElementById('ep_test_planner')?.addEventListener('click', async () => { - try { - const fake = '(测试输入)我想让你帮我规划下一步剧情。'; - await runPlanningOnce(fake, true); - toastInfo('测试完成:去 Logs 查看。'); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - // API - document.getElementById('ep_api_channel')?.addEventListener('change', (e) => { ensureSettings().api.channel = e.target.value; save(); }); - document.getElementById('ep_api_base')?.addEventListener('change', (e) => { ensureSettings().api.baseUrl = e.target.value.trim(); save(); }); - document.getElementById('ep_prefix_mode')?.addEventListener('change', (e) => { ensureSettings().api.prefixMode = e.target.value; save(); }); - document.getElementById('ep_prefix_custom')?.addEventListener('change', (e) => { ensureSettings().api.customPrefix = e.target.value.trim(); save(); }); - document.getElementById('ep_api_key')?.addEventListener('change', (e) => { ensureSettings().api.apiKey = e.target.value; save(); }); - document.getElementById('ep_model')?.addEventListener('change', (e) => { ensureSettings().api.model = e.target.value.trim(); save(); }); - document.getElementById('ep_stream')?.addEventListener('change', (e) => { ensureSettings().api.stream = e.target.value === 'true'; save(); }); - document.getElementById('ep_temp')?.addEventListener('change', (e) => { ensureSettings().api.temperature = Number(e.target.value); save(); }); - document.getElementById('ep_top_p')?.addEventListener('change', (e) => { ensureSettings().api.top_p = Number(e.target.value); save(); }); - document.getElementById('ep_top_k')?.addEventListener('change', (e) => { ensureSettings().api.top_k = Number(e.target.value) || 0; save(); }); - document.getElementById('ep_pp')?.addEventListener('change', (e) => { ensureSettings().api.presence_penalty = e.target.value.trim(); save(); }); - document.getElementById('ep_fp')?.addEventListener('change', (e) => { ensureSettings().api.frequency_penalty = e.target.value.trim(); save(); }); - document.getElementById('ep_mt')?.addEventListener('change', (e) => { ensureSettings().api.max_tokens = e.target.value.trim(); save(); }); - - document.getElementById('ep_test_conn')?.addEventListener('click', async () => { - try { - const models = await fetchModels(); - toastInfo(`连接成功:${models.length} 个模型`); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - document.getElementById('ep_fetch_models')?.addEventListener('click', async () => { - try { - const models = await fetchModels(); - toastInfo(`拉取成功:${models.length} 个模型`); - state.logs.unshift({ - time: nowISO(), ok: true, model: 'GET /models', - requestMessages: [], rawReply: safeStringify(models), filteredReply: safeStringify(models) - }); - clampLogs(); persistLogsMaybe(); - openLogModal(); renderLogs(); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - // Prompt designer - document.getElementById('ep_add_prompt')?.addEventListener('click', () => { - const s = ensureSettings(); - s.promptBlocks.push({ - id: crypto?.randomUUID?.() ?? String(Date.now()), - role: 'system', name: 'New Block', content: '' - }); - save(); renderPromptDesigner(); - }); - - document.getElementById('ep_reset_prompt')?.addEventListener('click', () => { - extension_settings[EXT_NAME].promptBlocks = getDefaultSettings().promptBlocks; - save(); renderPromptDesigner(); - }); - - // Template management - document.getElementById('ep_tpl_save')?.addEventListener('click', () => { - const sel = document.getElementById('ep_tpl_select'); - const name = sel?.value; - if (!name) { toastWarn('请先选择一个模板再储存'); return; } - const s = ensureSettings(); - if (!s.promptTemplates) s.promptTemplates = {}; - s.promptTemplates[name] = JSON.parse(JSON.stringify(s.promptBlocks || [])); - save(); - toastInfo(`模板「${name}」已覆盖保存`); - }); - document.getElementById('ep_tpl_select')?.addEventListener('change', (e) => { - const name = e.target.value; - if (!name) return; // 选的是占位符,不做事 - const s = ensureSettings(); - const tpl = s.promptTemplates?.[name]; - if (!tpl) { toastWarn('模板不存在'); return; } - s.promptBlocks = JSON.parse(JSON.stringify(tpl)).map(b => ({ - ...b, id: crypto?.randomUUID?.() ?? String(Date.now() + Math.random()) - })); - save(); renderPromptDesigner(); - toastInfo(`模板「${name}」已载入`); - }); - document.getElementById('ep_tpl_saveas')?.addEventListener('click', () => { - const name = prompt('请输入新模板名称:'); - if (!name || !name.trim()) return; - const s = ensureSettings(); - if (!s.promptTemplates) s.promptTemplates = {}; - s.promptTemplates[name.trim()] = JSON.parse(JSON.stringify(s.promptBlocks || [])); - save(); - refreshTemplateSelect(name.trim()); // 刷新并选中新模板 - toastInfo(`模板「${name.trim()}」已保存`); - }); - - document.getElementById('ep_tpl_delete')?.addEventListener('click', () => { - const sel = document.getElementById('ep_tpl_select'); - const name = sel?.value; - if (!name) { toastWarn('请先选择要删除的模板'); return; } - if (!confirm(`确定删除模板「${name}」?`)) return; - const s = ensureSettings(); - if (s.promptTemplates) delete s.promptTemplates[name]; - save(); - refreshTemplateSelect(); - toastInfo(`模板「${name}」已删除`); - }); - - function refreshTemplateSelect(selectName) { - const sel = document.getElementById('ep_tpl_select'); - if (!sel) return; - const s = ensureSettings(); - const names = Object.keys(s.promptTemplates || {}); - sel.innerHTML = '' + - names.map(n => ``).join(''); - } - - document.getElementById('ep_prompt_list')?.addEventListener('input', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const b = s.promptBlocks.find(x => x.id === id); - if (!b) return; - if (e.target.classList.contains('ep_pb_name')) b.name = e.target.value; - if (e.target.classList.contains('ep_pb_content')) b.content = e.target.value; - save(); - }); - - document.getElementById('ep_prompt_list')?.addEventListener('change', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const b = s.promptBlocks.find(x => x.id === id); - if (!b) return; - if (e.target.classList.contains('ep_pb_role')) b.role = e.target.value; - save(); - }); - - document.getElementById('ep_prompt_list')?.addEventListener('click', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const idx = s.promptBlocks.findIndex(x => x.id === id); - if (idx < 0) return; - - if (e.target.classList.contains('ep_pb_del')) { - s.promptBlocks.splice(idx, 1); save(); renderPromptDesigner(); - } - if (e.target.classList.contains('ep_pb_up') && idx > 0) { - [s.promptBlocks[idx - 1], s.promptBlocks[idx]] = [s.promptBlocks[idx], s.promptBlocks[idx - 1]]; - save(); renderPromptDesigner(); - } - if (e.target.classList.contains('ep_pb_down') && idx < s.promptBlocks.length - 1) { - [s.promptBlocks[idx + 1], s.promptBlocks[idx]] = [s.promptBlocks[idx], s.promptBlocks[idx + 1]]; - save(); renderPromptDesigner(); - } - }); - - // Debug buttons - document.getElementById('ep_debug_worldbook')?.addEventListener('click', async () => { - const out = document.getElementById('ep_debug_output'); - if (!out) return; - out.style.display = 'block'; - out.textContent = '正在诊断世界书读取...\n'; - try { - const charWb = await getCharacterWorldbooks(); - out.textContent += `角色世界书名称: ${JSON.stringify(charWb)}\n`; - const globalWb = await getGlobalWorldbooks(); - out.textContent += `全局世界书名称: ${JSON.stringify(globalWb)}\n`; - const all = [...new Set([...charWb, ...globalWb])]; - for (const name of all) { +async function debugWorldbookForUi() { + let out = '正在诊断世界书读取...\n'; + const charWb = await getCharacterWorldbooks(); + out += `角色世界书名称: ${JSON.stringify(charWb)}\n`; + const globalWb = await getGlobalWorldbooks(); + out += `全局世界书名称: ${JSON.stringify(globalWb)}\n`; + const all = [...new Set([...charWb, ...globalWb])]; + for (const name of all) { const data = await getWorldbookData(name); const count = data?.entries?.length ?? 0; const enabled = data?.entries?.filter(e => !e?.disable && !e?.disabled)?.length ?? 0; - out.textContent += ` "${name}": ${count} 条目, ${enabled} 已启用\n`; - } - if (!all.length) { - out.textContent += '⚠️ 未找到任何世界书。请检查角色卡是否绑定了世界书。\n'; - // Extra diagnostics + out += ` "${name}": ${count} 条目, ${enabled} 已启用\n`; + } + if (!all.length) { + out += '⚠️ 未找到任何世界书。请检查角色卡是否绑定了世界书。\n'; const charObj = getCurrentCharSafe(); - out.textContent += `charObj存在: ${!!charObj}\n`; + out += `charObj存在: ${!!charObj}\n`; if (charObj) { - out.textContent += `charObj.world: ${charObj?.world}\n`; - out.textContent += `charObj.data.extensions.world: ${charObj?.data?.extensions?.world}\n`; + out += `charObj.world: ${charObj?.world}\n`; + out += `charObj.data.extensions.world: ${charObj?.data?.extensions?.world}\n`; } const ctx = getContextSafe(); - out.textContent += `ctx存在: ${!!ctx}\n`; + out += `ctx存在: ${!!ctx}\n`; if (ctx) { - out.textContent += `ctx.characterId: ${ctx?.characterId}\n`; - out.textContent += `ctx.this_chid: ${ctx?.this_chid}\n`; + out += `ctx.characterId: ${ctx?.characterId}\n`; + out += `ctx.this_chid: ${ctx?.this_chid}\n`; } - } - } catch (e) { out.textContent += `错误: ${e?.message ?? e}\n`; } - }); + } + return out; +} - document.getElementById('ep_debug_char')?.addEventListener('click', () => { - const out = document.getElementById('ep_debug_output'); - if (!out) return; - out.style.display = 'block'; +function debugCharForUi() { const charObj = getCurrentCharSafe(); if (!charObj) { - out.textContent = '⚠️ 未检测到角色。\n'; - const ctx = getContextSafe(); - out.textContent += `ctx: ${!!ctx}, ctx.characterId: ${ctx?.characterId}, ctx.this_chid: ${ctx?.this_chid}\n`; - out.textContent += `window.this_chid: ${window.this_chid}\n`; - out.textContent += `window.characters count: ${window.characters?.length ?? 'N/A'}\n`; - return; + const ctx = getContextSafe(); + return [ + '⚠️ 未检测到角色。', + `ctx: ${!!ctx}, ctx.characterId: ${ctx?.characterId}, ctx.this_chid: ${ctx?.this_chid}`, + `window.this_chid: ${window.this_chid}`, + `window.characters count: ${window.characters?.length ?? 'N/A'}` + ].join('\n'); } const block = formatCharCardBlock(charObj); - out.textContent = `角色名: ${charObj?.name}\n`; - out.textContent += `desc长度: ${(charObj?.description ?? '').length}\n`; - out.textContent += `personality长度: ${(charObj?.personality ?? '').length}\n`; - out.textContent += `scenario长度: ${(charObj?.scenario ?? '').length}\n`; - out.textContent += `world: ${charObj?.world ?? charObj?.data?.extensions?.world ?? '(无)'}\n`; - out.textContent += `---\n${block.slice(0, 500)}...\n`; - }); + return [ + `角色名: ${charObj?.name}`, + `desc长度: ${(charObj?.description ?? '').length}`, + `personality长度: ${(charObj?.personality ?? '').length}`, + `scenario长度: ${(charObj?.scenario ?? '').length}`, + `world: ${charObj?.world ?? charObj?.data?.extensions?.world ?? '(无)'}`, + `---\n${block.slice(0, 500)}...` + ].join('\n'); } -function injectUI() { - ensureSettings(); - loadPersistedLogsMaybe(); +/** + * ------------------------- + * Build planner messages + * -------------------------- + */ +function getPromptBlocksByRole(role) { + const s = ensureSettings(); + return (s.promptBlocks || []).filter(b => b?.role === role && String(b?.content ?? '').trim()); +} - if (document.getElementById('ena_planner_settings')) return; +async function buildPlannerMessages(rawUserInput) { + const s = ensureSettings(); + const ctx = getContextSafe(); + const chat = ctx?.chat ?? window.SillyTavern?.chat ?? []; + const charObj = getCurrentCharSafe(); + const env = await prepareEjsEnv(); + const messageVars = getLatestMessageVarTable(); - // 动态注入 tab 按钮 - const menuBar = document.querySelector('.settings-menu-vertical'); - if (!menuBar) return; - if (!menuBar.querySelector('[data-target="ena-planner"]')) { - const tabDiv = document.createElement('div'); - tabDiv.className = 'menu-tab'; - tabDiv.setAttribute('data-target', 'ena-planner'); - tabDiv.setAttribute('style', 'border-bottom:1px solid #303030;'); - tabDiv.innerHTML = '剧情规划'; - menuBar.appendChild(tabDiv); - } + const enaSystemBlocks = getPromptBlocksByRole('system'); + const enaAssistantBlocks = getPromptBlocksByRole('assistant'); + const enaUserBlocks = getPromptBlocksByRole('user'); - // 动态注入面板容器 - const contentArea = document.querySelector('.settings-content'); - if (!contentArea) return; - if (!document.getElementById('ena_planner_panel')) { - const panel = document.createElement('div'); - panel.id = 'ena_planner_panel'; - panel.className = 'ena-planner settings-section'; - panel.style.display = 'none'; - contentArea.appendChild(panel); - } + const charBlockRaw = formatCharCardBlock(charObj); - const container = document.getElementById('ena_planner_panel'); - if (!container) return; - - const wrap = document.createElement('div'); - wrap.innerHTML = createSettingsHTML(); - while (wrap.firstChild) container.appendChild(wrap.firstChild); - - // Log modal - if (!document.getElementById('ep_log_modal')) { - const modalWrap = document.createElement('div'); - modalWrap.innerHTML = createLogModalHTML(); - while (modalWrap.firstChild) document.body.appendChild(modalWrap.firstChild); - - _addUniversalTap(document.getElementById('ep_log_close'), () => closeLogModal()); - const logModal = document.getElementById('ep_log_modal'); - if (logModal) { - _addUniversalTap(logModal, (e) => { if (e.target === logModal) closeLogModal(); }); + // --- Story memory: try fresh vector recall with current user input --- + let cachedSummary = ''; + let recallSource = 'none'; + try { + const vectorCfg = getVectorConfig(); + if (vectorCfg?.enabled) { + const result = await runWithTimeout( + () => buildVectorPromptText(false, { + pendingUserMessage: rawUserInput, + }), + VECTOR_RECALL_TIMEOUT_MS, + `向量召回超时(>${Math.floor(VECTOR_RECALL_TIMEOUT_MS / 1000)}s)` + ); + cachedSummary = result?.text?.trim() || ''; + if (cachedSummary) recallSource = 'fresh'; + } + } catch (e) { + console.warn('[Ena] Fresh vector recall failed, falling back to cached data:', e); } - document.getElementById('ep_log_clear')?.addEventListener('click', () => { - state.logs = []; persistLogsMaybe(); renderLogs(); - }); - document.getElementById('ep_log_export')?.addEventListener('click', () => { - try { - const blob = new Blob([JSON.stringify(state.logs, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `ena-planner-logs-${Date.now()}.json`; a.click(); - URL.revokeObjectURL(url); - } catch (e) { toastErr('导出失败:' + String(e?.message ?? e)); } - }); - } + if (!cachedSummary) { + cachedSummary = getCachedStorySummary(); + if (cachedSummary) recallSource = 'stale'; + } + console.log(`[Ena] Story memory source: ${recallSource}`); - bindSettingsUI(); + // --- Chat history: last 2 AI messages (floors N-1 & N-3) --- + // Two messages instead of one to avoid cross-device cache miss: + // story_summary cache is captured during main AI generation, so if + // user switches device and triggers Ena before a new generation, + // having N-3 as backup context prevents a gap. + const recentChatRaw = collectRecentChatSnippet(chat, 2); + + const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount)); + const vectorRaw = ''; + + // Build scanText for worldbook keyword activation + const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n'); + + const worldbookRaw = await buildWorldbookBlock(scanText); + + // Render templates/macros + const charBlock = await renderTemplateAll(charBlockRaw, env, messageVars); + const recentChat = await renderTemplateAll(recentChatRaw, env, messageVars); + const plots = await renderTemplateAll(plotsRaw, env, messageVars); + const vector = await renderTemplateAll(vectorRaw, env, messageVars); + const storySummary = cachedSummary.trim().length > 30 ? await renderTemplateAll(cachedSummary, env, messageVars) : ''; + const worldbook = await renderTemplateAll(worldbookRaw, env, messageVars); + const userInput = await renderTemplateAll(rawUserInput, env, messageVars); + + const messages = []; + + // 1) Ena system prompts + for (const b of enaSystemBlocks) { + const content = await renderTemplateAll(b.content, env, messageVars); + messages.push({ role: 'system', content }); + } + + // 2) Character card + if (String(charBlock).trim()) messages.push({ role: 'system', content: charBlock }); + + // 3) Worldbook + if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook }); + + // 4) Chat history (last 2 AI responses — floors N-1 & N-3) + if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat }); + + // 4.5) Story memory (小白X <剧情记忆> — after chat context, before plots) + if (storySummary.trim()) { + messages.push({ role: 'system', content: `\n${storySummary}\n` }); + } + + // 5) Vector recall — merged into story_summary above, kept for compatibility + // (vectorRaw is empty; this block intentionally does nothing) + if (String(vector).trim()) messages.push({ role: 'system', content: vector }); + + // 6) Previous plots + if (String(plots).trim()) messages.push({ role: 'system', content: plots }); + + // 7) User input (with friendly framing) + const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`; + messages.push({ role: 'user', content: userMsgContent }); + + // Extra user blocks before user message + for (const b of enaUserBlocks) { + const content = await renderTemplateAll(b.content, env, messageVars); + messages.splice(Math.max(0, messages.length - 1), 0, { role: 'system', content: `【extra-user-block】\n${content}` }); + } + + // 8) Assistant blocks + for (const b of enaAssistantBlocks) { + const content = await renderTemplateAll(b.content, env, messageVars); + messages.push({ role: 'assistant', content }); + } + + return { messages, meta: { charBlockRaw, worldbookRaw, recentChatRaw, vectorRaw, cachedSummaryLen: cachedSummary.length, plotsRaw } }; } -/** ------------------------- +/** + * ------------------------- * Planning runner + logging - * --------------------------*/ -async function runPlanningOnce(rawUserInput, silent = false) { - const s = ensureSettings(); + * -------------------------- + */ +async function runPlanningOnce(rawUserInput, silent = false, options = {}) { + const s = ensureSettings(); - const log = { - time: nowISO(), ok: false, model: s.api.model, - requestMessages: [], rawReply: '', filteredReply: '', error: '' - }; + const log = { + time: nowISO(), ok: false, model: s.api.model, + requestMessages: [], rawReply: '', filteredReply: '', error: '' + }; - try { - const { messages } = await buildPlannerMessages(rawUserInput); - log.requestMessages = messages; + try { + const { messages } = await buildPlannerMessages(rawUserInput); + log.requestMessages = messages; - const rawReply = await callPlanner(messages); - log.rawReply = rawReply; + const rawReply = await callPlanner(messages, options); + log.rawReply = rawReply; - const filtered = filterPlannerForInput(rawReply); - log.filteredReply = filtered; - log.ok = true; + const filtered = filterPlannerForInput(rawReply); + log.filteredReply = filtered; + log.ok = true; - state.logs.unshift(log); clampLogs(); persistLogsMaybe(); - return { rawReply, filtered }; - } catch (e) { - log.error = String(e?.message ?? e); - state.logs.unshift(log); clampLogs(); persistLogsMaybe(); - if (!silent) toastErr(log.error); - throw e; - } + state.logs.unshift(log); clampLogs(); persistLogsMaybe(); + return { rawReply, filtered }; + } catch (e) { + log.error = String(e?.message ?? e); + state.logs.unshift(log); clampLogs(); persistLogsMaybe(); + if (!silent) toastErr(log.error); + throw e; + } } -/** ------------------------- +/** + * ------------------------- * Intercept send - * --------------------------*/ + * -------------------------- + */ function getSendTextarea() { return document.getElementById('send_textarea'); } function getSendButton() { return document.getElementById('send_but') || document.getElementById('send_button'); } function shouldInterceptNow() { - const s = ensureSettings(); - if (!s.enabled || state.isPlanning) return false; - const ta = getSendTextarea(); - if (!ta) return false; - const txt = String(ta.value ?? '').trim(); - if (!txt) return false; - if (state.bypassNextSend) return false; - if (s.skipIfPlotPresent && / { state.bypassNextSend = false; }, 800); - } + state.bypassNextSend = true; + btn.click(); + } catch (err) { + ta.value = raw; + state.lastInjectedText = ''; + throw err; + } finally { + state.isPlanning = false; + setSendUIBusy(false); + setTimeout(() => { state.bypassNextSend = false; }, 800); + } } function installSendInterceptors() { - document.addEventListener('click', (e) => { - const btn = getSendButton(); - if (!btn) return; - if (e.target !== btn && !btn.contains(e.target)) return; - if (!shouldInterceptNow()) return; - e.preventDefault(); - e.stopImmediatePropagation(); - doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); - }, true); - - document.addEventListener('keydown', (e) => { - const ta = getSendTextarea(); - if (!ta || e.target !== ta) return; - if (e.key === 'Enter' && !e.shiftKey) { - if (!shouldInterceptNow()) return; - e.preventDefault(); - e.stopImmediatePropagation(); - doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); - } - }, true); -} - -export function initEnaPlanner() { - ensureSettings(); - loadPersistedLogsMaybe(); - - const tryInject = () => { - if (document.querySelector('.settings-menu-vertical')) { - injectUI(); - installSendInterceptors(); - } else { - setTimeout(tryInject, 500); + if (sendListenersInstalled) return; + sendClickHandler = (e) => { + const btn = getSendButton(); + if (!btn) return; + if (e.target !== btn && !btn.contains(e.target)) return; + if (!shouldInterceptNow()) return; + e.preventDefault(); + e.stopImmediatePropagation(); + doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); + }; + sendKeydownHandler = (e) => { + const ta = getSendTextarea(); + if (!ta || e.target !== ta) return; + if (e.key === 'Enter' && !e.shiftKey) { + if (!shouldInterceptNow()) return; + e.preventDefault(); + e.stopImmediatePropagation(); + doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); } }; - tryInject(); + document.addEventListener('click', sendClickHandler, true); + document.addEventListener('keydown', sendKeydownHandler, true); + sendListenersInstalled = true; +} + +function uninstallSendInterceptors() { + if (!sendListenersInstalled) return; + if (sendClickHandler) document.removeEventListener('click', sendClickHandler, true); + if (sendKeydownHandler) document.removeEventListener('keydown', sendKeydownHandler, true); + sendClickHandler = null; + sendKeydownHandler = null; + sendListenersInstalled = false; +} + +function getIframeConfigPayload() { + const s = ensureSettings(); + return { + ...s, + logs: state.logs, + }; +} + +function openSettings() { + if (document.getElementById(OVERLAY_ID)) return; + + overlay = document.createElement('div'); + overlay.id = OVERLAY_ID; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: ${window.innerHeight}px; + background: rgba(0,0,0,0.5); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + `; + + const iframe = document.createElement('iframe'); + iframe.src = HTML_PATH; + iframe.style.cssText = ` + width: min(1200px, 96vw); + height: min(980px, 94vh); + max-height: calc(100% - 24px); + border: none; + border-radius: 12px; + background: #1a1a1a; + `; + + overlay.appendChild(iframe); + document.body.appendChild(overlay); + + if (!iframeMessageBound) { + // Guarded by isTrustedIframeEvent (origin + source). + // eslint-disable-next-line no-restricted-syntax + window.addEventListener('message', handleIframeMessage); + iframeMessageBound = true; + } +} + +function closeSettings() { + const overlayEl = document.getElementById(OVERLAY_ID); + if (overlayEl) overlayEl.remove(); + overlay = null; +} + +async function handleIframeMessage(ev) { + const iframe = overlay?.querySelector('iframe'); + if (!isTrustedIframeEvent(ev, iframe)) return; + if (!ev.data?.type?.startsWith('xb-ena:')) return; + + const { type, payload } = ev.data; + switch (type) { + case 'xb-ena:ready': + postToIframe(iframe, { type: 'xb-ena:config', payload: getIframeConfigPayload() }); + break; + case 'xb-ena:close': + closeSettings(); + break; + case 'xb-ena:save-config': { + const requestId = payload?.requestId || ''; + const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload; + Object.assign(ensureSettings(), patch || {}); + const ok = await saveConfigNow(); + if (ok) { + postToIframe(iframe, { + type: 'xb-ena:config-saved', + payload: { + ...getIframeConfigPayload(), + requestId + } + }); + } else { + postToIframe(iframe, { + type: 'xb-ena:config-save-error', + payload: { + message: '保存失败', + requestId + } + }); + } + break; + } + case 'xb-ena:reset-prompt-default': { + const requestId = payload?.requestId || ''; + const s = ensureSettings(); + s.promptBlocks = getDefaultSettings().promptBlocks; + const ok = await saveConfigNow(); + if (ok) { + postToIframe(iframe, { + type: 'xb-ena:config-saved', + payload: { + ...getIframeConfigPayload(), + requestId + } + }); + } else { + postToIframe(iframe, { + type: 'xb-ena:config-save-error', + payload: { + message: '重置失败', + requestId + } + }); + } + break; + } + case 'xb-ena:run-test': { + try { + const fake = payload?.text || '(测试输入)我想让你帮我规划下一步剧情。'; + await runPlanningOnce(fake, true); + postToIframe(iframe, { type: 'xb-ena:test-done' }); + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:test-error', payload: { message: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:logs-request': + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + break; + case 'xb-ena:logs-clear': + state.logs = []; + await saveConfigNow(); + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + break; + case 'xb-ena:fetch-models': { + try { + const models = await fetchModelsForUi(); + postToIframe(iframe, { type: 'xb-ena:models', payload: { models } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:models-error', payload: { message: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:debug-worldbook': { + try { + const output = await debugWorldbookForUi(); + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:debug-char': { + const output = debugCharForUi(); + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } }); + break; + } + } +} + +export async function initEnaPlanner() { + await loadConfig(); + loadPersistedLogsMaybe(); + installSendInterceptors(); + window.xiaobaixEnaPlanner = { openSettings, closeSettings }; +} + +export function cleanupEnaPlanner() { + uninstallSendInterceptors(); + closeSettings(); + if (iframeMessageBound) { + window.removeEventListener('message', handleIframeMessage); + iframeMessageBound = false; + } + delete window.xiaobaixEnaPlanner; } diff --git a/modules/scheduled-tasks/scheduled-tasks.js b/modules/scheduled-tasks/scheduled-tasks.js index 4d92f03..a1d0627 100644 --- a/modules/scheduled-tasks/scheduled-tasks.js +++ b/modules/scheduled-tasks/scheduled-tasks.js @@ -679,6 +679,9 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') { const listeners = new Set(); const createdNodes = new Set(); const waiters = new Set(); + let suppressTimerTracking = false; + const originalToastrFns = {}; + const toastrMethods = ['info', 'success', 'warning', 'error']; const notifyActivityChange = () => { if (waiters.size === 0) return; @@ -689,16 +692,36 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') { window.setTimeout = function(fn, t, ...args) { const id = originals.setTimeout(function(...inner) { - try { fn?.(...inner); } finally { timeouts.delete(id); notifyActivityChange(); } + try { fn?.(...inner); } + finally { + if (timeouts.delete(id)) notifyActivityChange(); + } }, t, ...args); - timeouts.add(id); - notifyActivityChange(); + if (!suppressTimerTracking) { + timeouts.add(id); + notifyActivityChange(); + } return id; }; - window.clearTimeout = function(id) { originals.clearTimeout(id); timeouts.delete(id); notifyActivityChange(); }; + window.clearTimeout = function(id) { + originals.clearTimeout(id); + if (timeouts.delete(id)) notifyActivityChange(); + }; window.setInterval = function(fn, t, ...args) { const id = originals.setInterval(fn, t, ...args); intervals.add(id); notifyActivityChange(); return id; }; window.clearInterval = function(id) { originals.clearInterval(id); intervals.delete(id); notifyActivityChange(); }; + if (window.toastr) { + for (const method of toastrMethods) { + if (typeof window.toastr[method] !== 'function') continue; + originalToastrFns[method] = window.toastr[method]; + window.toastr[method] = function(...fnArgs) { + suppressTimerTracking = true; + try { return originalToastrFns[method].apply(window.toastr, fnArgs); } + finally { suppressTimerTracking = false; } + }; + } + } + const addListenerEntry = (entry) => { listeners.add(entry); notifyActivityChange(); }; const removeListenerEntry = (target, type, listener, options) => { let removed = false; @@ -736,6 +759,13 @@ async function executeTaskJS(jsCode, taskName = 'AnonymousTask') { Node.prototype.appendChild = originals.appendChild; Node.prototype.insertBefore = originals.insertBefore; Node.prototype.replaceChild = originals.replaceChild; + if (window.toastr) { + for (const method of toastrMethods) { + if (typeof originalToastrFns[method] === 'function') { + window.toastr[method] = originalToastrFns[method]; + } + } + } }; const hardCleanup = () => { diff --git a/modules/story-summary/story-summary.js b/modules/story-summary/story-summary.js index ba6e0ea..40d1c01 100644 --- a/modules/story-summary/story-summary.js +++ b/modules/story-summary/story-summary.js @@ -1029,6 +1029,12 @@ function buildFramePayload(store) { }; } +// Compatibility export for ena-planner. +// Returns a compact plain-text snapshot of story-summary memory. +export function getStorySummaryForEna() { + return _lastBuiltPromptText; +} + function parseRelationTargetFromPredicate(predicate) { const text = String(predicate || "").trim(); if (!text.startsWith("对")) return null; @@ -1093,6 +1099,57 @@ function mergeCharacterRelationshipsIntoFacts(existingFacts, relationships, floo return [...nonRelationFacts, ...newRelationFacts]; } +function getCurrentFloorHint() { + const { chat } = getContext(); + const lastFloor = (Array.isArray(chat) ? chat.length : 0) - 1; + return Math.max(0, lastFloor); +} + +function factKeyBySubjectPredicate(fact) { + const s = String(fact?.s || "").trim(); + const p = String(fact?.p || "").trim(); + return `${s}::${p}`; +} + +function mergeEditedFactsWithTimestamps(existingFacts, editedFacts, floorHint = 0) { + const currentFacts = Array.isArray(existingFacts) ? existingFacts : []; + const incomingFacts = Array.isArray(editedFacts) ? editedFacts : []; + const oldMap = new Map(currentFacts.map((f) => [factKeyBySubjectPredicate(f), f])); + + let nextFactId = getNextFactIdValue(currentFacts); + const merged = []; + + for (const fact of incomingFacts) { + const s = String(fact?.s || "").trim(); + const p = String(fact?.p || "").trim(); + const o = String(fact?.o || "").trim(); + if (!s || !p || !o) continue; + + const key = `${s}::${p}`; + const oldFact = oldMap.get(key); + const since = oldFact?.since ?? fact?.since ?? floorHint; + const addedAt = oldFact?._addedAt ?? fact?._addedAt ?? floorHint; + + const out = { + id: oldFact?.id || fact?.id || `f-${nextFactId++}`, + s, + p, + o, + since, + _addedAt: addedAt, + }; + if (oldFact?._isState != null) out._isState = oldFact._isState; + + const mergedTrend = fact?.trend ?? oldFact?.trend; + if (mergedTrend != null && String(mergedTrend).trim()) { + out.trend = String(mergedTrend).trim(); + } + merged.push(out); + } + + return merged; +} + function openPanelForMessage(mesId) { createOverlay(); showOverlay(); @@ -1429,13 +1486,17 @@ async function handleFrameMessage(event) { // 如果是 events,先记录旧数据用于同步向量 const oldEvents = data.section === "events" ? [...(store.json.events || [])] : null; + const oldFacts = data.section === "facts" ? [...(store.json.facts || [])] : null; if (VALID_SECTIONS.includes(data.section)) { store.json[data.section] = data.data; } + if (data.section === "facts") { + store.json.facts = mergeEditedFactsWithTimestamps(oldFacts, data.data, getCurrentFloorHint()); + } if (data.section === "characters") { const rels = data?.data?.relationships || []; - const floorHint = Math.max(0, Number(store.lastSummarizedMesId) || 0); + const floorHint = getCurrentFloorHint(); store.json.facts = mergeCharacterRelationshipsIntoFacts(store.json.facts, rels, floorHint); } store.updatedAt = Date.now(); @@ -1757,13 +1818,14 @@ async function handleGenerationStarted(type, _params, isDryRun) { } else { text = buildNonVectorPromptText() || ""; } + _lastBuiltPromptText = text; if (!text.trim()) return; // 获取用户配置的 role const cfg = getSummaryPanelConfig(); const roleKey = cfg.trigger?.role || 'system'; const role = ROLE_MAP[roleKey] || extension_prompt_roles.SYSTEM; - _lastBuiltPromptText = text; + // 写入 extension_prompts extension_prompts[EXT_PROMPT_KEY] = { value: text, @@ -1877,6 +1939,3 @@ jQuery(() => { maybePreloadTokenizer(); }); -export function getStorySummaryForEna() { - return _lastBuiltPromptText; -} diff --git a/modules/tts/tts-api.js b/modules/tts/tts-api.js index 7fb38cb..0943f41 100644 --- a/modules/tts/tts-api.js +++ b/modules/tts/tts-api.js @@ -1,23 +1,33 @@ -/** +/** * 火山引擎 TTS API 封装 * V3 单向流式 + V1试用 */ const V3_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional'; -const FREE_V1_URL = 'https://hstts.velure.codes'; +const FREE_V1_URL = 'https://edgetts.velure.codes'; export const FREE_VOICES = [ - { key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' }, - { key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' }, - { key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' }, - { key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' }, - { key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' }, - { key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' }, - { key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' }, - { key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' }, - { key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' }, - { key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' }, - { key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' }, + { key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' }, + { key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' }, + { key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' }, + { key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' }, + { key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' }, + { key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' }, + { key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' }, + { key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' }, + { key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' }, + { key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' }, + { key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' }, + { key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' }, + { key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' }, + { key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' }, + { key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' }, + { key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' }, + { key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' }, + { key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' }, + { key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' }, + { key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' }, + { key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' }, ]; export const FREE_DEFAULT_VOICE = 'female_1'; @@ -333,3 +343,4 @@ export async function synthesizeFreeV1(params, options = {}) { return { audioBase64: data.data }; } + diff --git a/modules/tts/tts-overlay.html b/modules/tts/tts-overlay.html index 34afe7e..e79a5fa 100644 --- a/modules/tts/tts-overlay.html +++ b/modules/tts/tts-overlay.html @@ -1,4 +1,4 @@ - + @@ -1275,7 +1275,7 @@ select.input { cursor: pointer; }
- 试用音色 — 无需配置,使用插件服务器(11个音色)
+ 试用音色 — 无需配置,使用插件服务器(21个音色)
鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻)
@@ -1719,19 +1719,30 @@ let selectedTrialVoiceValue = ''; let selectedAuthVoiceValue = ''; let editingVoiceValue = null; let activeSaveBtn = null; +let pendingSaveRequest = null; const TRIAL_VOICES = [ - { key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' }, - { key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' }, - { key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' }, - { key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' }, - { key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' }, - { key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' }, - { key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' }, - { key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' }, - { key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' }, - { key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' }, - { key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' }, + { key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' }, + { key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' }, + { key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' }, + { key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' }, + { key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' }, + { key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' }, + { key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' }, + { key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' }, + { key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' }, + { key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' }, + { key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' }, + { key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' }, + { key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' }, + { key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' }, + { key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' }, + { key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' }, + { key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' }, + { key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' }, + { key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' }, + { key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' }, + { key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' }, ]; const TRIAL_VOICE_KEYS = new Set(TRIAL_VOICES.map(v => v.key)); @@ -1781,6 +1792,25 @@ function handleSaveResult(success) { } } +function requestSaveConfig(form, btn = null) { + const requestId = `tts_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + if (btn) setSavingState(btn); + + pendingSaveRequest = { + requestId, + timer: setTimeout(() => { + if (!pendingSaveRequest || pendingSaveRequest.requestId !== requestId) return; + pendingSaveRequest = null; + handleSaveResult(false); + post('xb-tts:toast', { type: 'error', message: '保存超时(3秒)' }); + }, 3000), + }; + + post('xb-tts:save-config', { requestId, patch: form }); +} + function setTestStatus(elId, status, text) { const el = $(elId); if (!el) return; @@ -2050,7 +2080,7 @@ function bindMyVoiceEvents(listEl) { const input = btn.closest('.voice-item').querySelector('.voice-edit-input'); if (item && input?.value?.trim()) { item.name = input.value.trim(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); } editingVoiceValue = null; renderMyVoiceList(); @@ -2080,7 +2110,7 @@ function bindMyVoiceEvents(listEl) { renderTrialVoiceList(); renderAuthVoiceList(); updateCurrentVoiceDisplay(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); } }); }); @@ -2163,7 +2193,12 @@ function normalizeMySpeakers(list) { value: String(item?.value || '').trim(), source: item?.source || getVoiceSource(item?.value || ''), resourceId: item?.resourceId || null, - })).filter(item => item.value); + })).filter(item => { + if (!item.value) return false; + // Keep UI behavior aligned with runtime: remove unsupported legacy free voices. + if (item.source === 'free' && !TRIAL_VOICE_KEYS.has(item.value)) return false; + return true; + }); } function applyCacheStats(stats = {}) { @@ -2298,11 +2333,17 @@ window.addEventListener('message', ev => { fillForm(payload); break; case 'xb-tts:config-saved': + if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break; + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + pendingSaveRequest = null; fillForm(payload); handleSaveResult(true); post('xb-tts:toast', { type: 'success', message: '配置已保存' }); break; case 'xb-tts:config-save-error': + if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break; + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + pendingSaveRequest = null; handleSaveResult(false); post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' }); break; @@ -2417,7 +2458,7 @@ document.addEventListener('DOMContentLoaded', () => { $$('.voice-tab')[0].classList.add('active'); $('panel-myVoice').classList.add('active'); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); }); @@ -2441,7 +2482,7 @@ document.addEventListener('DOMContentLoaded', () => { $$('.voice-tab')[0].classList.add('active'); $('panel-myVoice').classList.add('active'); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); }); @@ -2460,12 +2501,12 @@ document.addEventListener('DOMContentLoaded', () => { renderMyVoiceList(); updateCurrentVoiceDisplay(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` }); }); ['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => { - $(id)?.addEventListener('click', () => { setSavingState($(id)); post('xb-tts:save-config', collectForm()); }); + $(id)?.addEventListener('click', () => { requestSaveConfig(collectForm(), $(id)); }); }); $('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh')); @@ -2477,3 +2518,4 @@ document.addEventListener('DOMContentLoaded', () => { + diff --git a/modules/tts/tts-text.js b/modules/tts/tts-text.js index 80c186d..5d14a5a 100644 --- a/modules/tts/tts-text.js +++ b/modules/tts/tts-text.js @@ -169,7 +169,7 @@ export function parseTtsSegments(text) { // ============ 非鉴权分段切割 ============ -const FREE_MAX_TEXT = 200; +const FREE_MAX_TEXT = 1000; const FREE_MIN_TEXT = 50; const FREE_SENTENCE_DELIMS = new Set(['。', '!', '?', '!', '?', ';', ';', '…', '.', ',', ',', '、', ':', ':']); @@ -218,20 +218,98 @@ function splitTextForFree(text, maxLength = FREE_MAX_TEXT) { const chunks = []; const paragraphs = String(text || '').split(/\n\s*\n/).map(s => s.replace(/\n+/g, '\n').trim()).filter(Boolean); + let current = ''; + const pushCurrent = () => { + if (!current) return; + chunks.push(current); + current = ''; + }; + for (const para of paragraphs) { - if (para.length <= maxLength) { - chunks.push(para); + if (!para) continue; + + if (para.length > maxLength) { + // Flush buffered short paragraphs before handling a long paragraph. + pushCurrent(); + const longParts = splitLongTextBySentence(para, maxLength); + for (const part of longParts) { + const t = String(part || '').trim(); + if (!t) continue; + if (!current) { + current = t; + continue; + } + if (current.length + t.length + 2 <= maxLength) { + current += `\n\n${t}`; + continue; + } + pushCurrent(); + current = t; + } continue; } - chunks.push(...splitLongTextBySentence(para, maxLength)); + + if (!current) { + current = para; + continue; + } + + // Cross-paragraph merge: keep fewer requests while preserving paragraph boundary. + if (current.length + para.length + 2 <= maxLength) { + current += `\n\n${para}`; + continue; + } + + pushCurrent(); + current = para; } + + pushCurrent(); return chunks; } export function splitTtsSegmentsForFree(segments, maxLength = FREE_MAX_TEXT) { if (!Array.isArray(segments) || !segments.length) return []; - const out = []; + const normalizedSegments = []; + + // In free mode, only explicit speaker directives are semantic split points. + // Adjacent segments without speaker= are merged to reduce request count. + let mergeBuffer = null; + const flushMergeBuffer = () => { + if (!mergeBuffer) return; + normalizedSegments.push(mergeBuffer); + mergeBuffer = null; + }; + for (const seg of segments) { + const hasExplicitSpeaker = !!String(seg?.speaker || '').trim(); + const text = String(seg?.text || '').trim(); + if (!text) continue; + + if (hasExplicitSpeaker) { + flushMergeBuffer(); + normalizedSegments.push({ + ...seg, + text, + }); + continue; + } + + if (!mergeBuffer) { + mergeBuffer = { + ...seg, + text, + speaker: '', + }; + continue; + } + + mergeBuffer.text += `\n${text}`; + } + flushMergeBuffer(); + + const out = []; + for (const seg of normalizedSegments) { const parts = splitTextForFree(seg.text, maxLength); if (!parts.length) continue; let buffer = ''; diff --git a/modules/tts/tts.js b/modules/tts/tts.js index 33f784e..4c37cbb 100644 --- a/modules/tts/tts.js +++ b/modules/tts/tts.js @@ -1,4 +1,4 @@ -// ============ 导入 ============ +// ============ 导入 ============ import { event_types } from "../../../../../../script.js"; import { extension_settings, getContext } from "../../../../../extensions.js"; @@ -42,8 +42,12 @@ const HTML_PATH = `${extensionFolderPath}/modules/tts/tts-overlay.html`; const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi; const FREE_VOICE_KEYS = new Set([ - 'female_1', 'female_2', 'female_3', 'female_4', 'female_5', 'female_6', 'female_7', - 'male_1', 'male_2', 'male_3', 'male_4' + 'female_1', 'female_2', 'female_3', 'female_4', + 'hk_female_1', 'hk_female_2', 'hk_male_1', + 'tw_female_1', 'tw_female_2', 'tw_male_1', + 'male_1', 'male_2', 'male_3', 'male_4', + 'en_female_1', 'en_female_2', 'en_female_3', 'en_male_1', 'en_male_2', + 'ja_female_1', 'ja_male_1', ]); // ============ NovelDraw 兼容 ============ @@ -913,11 +917,26 @@ async function loadConfig() { config = await TtsStorage.load(); config.volc = config.volc || {}; + let legacyPurged = false; if (Array.isArray(config.volc.mySpeakers)) { - config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({ + const normalized = config.volc.mySpeakers.map(s => ({ ...s, source: s.source || getVoiceSource(s.value) })); + const filtered = normalized.filter(s => { + // Purge legacy free voices that are no longer supported by the current free voice map. + if (s.source === 'free' && !FREE_VOICE_KEYS.has(s.value)) { + legacyPurged = true; + return false; + } + return true; + }); + config.volc.mySpeakers = filtered; + } + + if (config.volc.defaultSpeaker && getVoiceSource(config.volc.defaultSpeaker) === 'free' && !FREE_VOICE_KEYS.has(config.volc.defaultSpeaker)) { + config.volc.defaultSpeaker = FREE_DEFAULT_VOICE; + legacyPurged = true; } config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false; @@ -943,6 +962,12 @@ async function loadConfig() { config.showFloorButton = config.showFloorButton !== false; config.showFloatingButton = config.showFloatingButton === true; + if (legacyPurged) { + await TtsStorage.set('volc', config.volc); + await TtsStorage.saveNow({ silent: true }); + console.info('[TTS] Purged legacy free voices from mySpeakers.'); + } + return config; } @@ -1054,15 +1079,17 @@ async function handleIframeMessage(ev) { closeSettings(); break; case 'xb-tts:save-config': { - const ok = await saveConfig(payload); + const requestId = payload?.requestId || ''; + const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload; + const ok = await saveConfig(patch); if (ok) { const cacheStats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } }); + postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats, requestId } }); updateAutoSpeakAll(); updateSpeedAll(); updateVoiceAll(); } else { - postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); + postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败', requestId } }); } break; } @@ -1472,3 +1499,4 @@ export function cleanupTts() { cacheCounters.misses = 0; delete window.xiaobaixTts; } + diff --git a/settings.html b/settings.html index 4d76b53..7d1804e 100644 --- a/settings.html +++ b/settings.html @@ -206,6 +206,14 @@ +
+ + + +

变量控制

@@ -519,14 +527,15 @@ audio: 'xiaobaix_audio_enabled', storySummary: 'xiaobaix_story_summary_enabled', tts: 'xiaobaix_tts_enabled', + enaPlanner: 'xiaobaix_ena_planner_enabled', storyOutline: 'xiaobaix_story_outline_enabled', useBlob: 'xiaobaix_use_blob', wrapperIframe: 'Wrapperiframe', renderEnabled: 'xiaobaix_render_enabled', }; const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; - const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; - const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts']; + const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; + const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; function setModuleEnabled(key, enabled) { try { if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};