From 74fc36c2b9f0ac14f09f4eaf26e8ac3d1c202d70 Mon Sep 17 00:00:00 2001 From: RT15548 Date: Sun, 21 Dec 2025 01:47:38 +0800 Subject: [PATCH] Add files via upload --- README.md | 138 +- bridges/call-generate-service.js | 2986 +++++------ bridges/worldbook-bridge.js | 1674 +++---- bridges/wrapper-iframe.js | 208 +- core/constants.js | 14 +- core/server-storage.js | 138 + core/slash-command.js | 60 +- docs/COPYRIGHT | 146 +- docs/LICENSE.md | 66 +- docs/NOTICE | 190 +- docs/script-docs.md | 3434 ++++++------- index.js | 123 +- manifest.json | 22 +- modules/button-collapse.js | 514 +- modules/control-audio.js | 536 +- modules/fourth-wall/fourth-wall.js | 69 +- modules/iframe-renderer.js | 1420 +++--- modules/immersive-mode.js | 946 ++-- modules/message-preview.js | 1298 ++--- modules/scheduled-tasks/embedded-tasks.html | 150 +- modules/scheduled-tasks/scheduled-tasks.html | 150 +- modules/scheduled-tasks/scheduled-tasks.js | 248 +- modules/script-assistant.js | 208 +- modules/story-outline/story-outline-prompt.js | 1477 +++--- modules/story-outline/story-outline.html | 3681 +++++++------- modules/story-outline/story-outline.js | 2297 +++++---- modules/story-summary/story-summary.html | 100 +- modules/story-summary/story-summary.js | 127 +- modules/streaming-generation.js | 109 +- modules/template-editor/template-editor.html | 122 +- modules/variables/varevent-editor.js | 14 +- modules/variables/variables-panel.js | 1358 ++--- modules/wallhaven-background.js | 4364 ++++++++--------- settings.html | 522 +- style.css | 942 ++-- 35 files changed, 15216 insertions(+), 14635 deletions(-) create mode 100644 core/server-storage.js diff --git a/README.md b/README.md index d2c78c9..f650a53 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,74 @@ -# LittleWhiteBox - -SillyTavern 扩展插件 - 小白X - -## 📁 目录结构 - -``` -LittleWhiteBox/ -├── manifest.json # 插件配置清单 -├── index.js # 主入口文件 -├── settings.html # 设置页面模板 -├── style.css # 全局样式 -│ -├── modules/ # 功能模块目录 -│ ├── streaming-generation.js # 流式生成 -│ ├── dynamic-prompt.js # 动态提示词 -│ ├── immersive-mode.js # 沉浸模式 -│ ├── message-preview.js # 消息预览 -│ ├── wallhaven-background.js # 壁纸背景 -│ ├── button-collapse.js # 按钮折叠 -│ ├── control-audio.js # 音频控制 -│ ├── script-assistant.js # 脚本助手 -│ │ -│ ├── variables/ # 变量系统 -│ │ ├── variables-core.js -│ │ └── variables-panel.js -│ │ -│ ├── template-editor/ # 模板编辑器 -│ │ ├── template-editor.js -│ │ └── template-editor.html -│ │ -│ ├── scheduled-tasks/ # 定时任务 -│ │ ├── scheduled-tasks.js -│ │ ├── scheduled-tasks.html -│ │ └── embedded-tasks.html -│ │ -│ ├── story-summary/ # 故事摘要 -│ │ ├── story-summary.js -│ │ └── story-summary.html -│ │ -│ └── story-outline/ # 故事大纲 -│ ├── story-outline.js -│ ├── story-outline-prompt.js -│ └── story-outline.html -│ -├── bridges/ # 外部桥接模块 -│ ├── worldbook-bridge.js # 世界书桥接 -│ ├── call-generate-service.js # 生成服务调用 -│ └── wrapper-iframe.js # iframe 包装器 -│ -├── ui/ # UI 模板 -│ └── character-updater-menus.html -│ -└── docs/ # 文档 - ├── script-docs.md # 脚本文档 - ├── LICENSE.md # 许可证 - ├── COPYRIGHT # 版权信息 - └── NOTICE # 声明 -``` - - -## 📄 许可证 - -详见 `docs/LICENSE.md` +# LittleWhiteBox + +SillyTavern 扩展插件 - 小白X + +## 📁 目录结构 + +``` +LittleWhiteBox/ +├── manifest.json # 插件配置清单 +├── index.js # 主入口文件 +├── settings.html # 设置页面模板 +├── style.css # 全局样式 +│ +├── modules/ # 功能模块目录 +│ ├── streaming-generation.js # 流式生成 +│ ├── dynamic-prompt.js # 动态提示词 +│ ├── immersive-mode.js # 沉浸模式 +│ ├── message-preview.js # 消息预览 +│ ├── wallhaven-background.js # 壁纸背景 +│ ├── button-collapse.js # 按钮折叠 +│ ├── control-audio.js # 音频控制 +│ ├── script-assistant.js # 脚本助手 +│ │ +│ ├── variables/ # 变量系统 +│ │ ├── variables-core.js +│ │ └── variables-panel.js +│ │ +│ ├── template-editor/ # 模板编辑器 +│ │ ├── template-editor.js +│ │ └── template-editor.html +│ │ +│ ├── scheduled-tasks/ # 定时任务 +│ │ ├── scheduled-tasks.js +│ │ ├── scheduled-tasks.html +│ │ └── embedded-tasks.html +│ │ +│ ├── story-summary/ # 故事摘要 +│ │ ├── story-summary.js +│ │ └── story-summary.html +│ │ +│ └── story-outline/ # 故事大纲 +│ ├── story-outline.js +│ ├── story-outline-prompt.js +│ └── story-outline.html +│ +├── bridges/ # 外部桥接模块 +│ ├── worldbook-bridge.js # 世界书桥接 +│ ├── call-generate-service.js # 生成服务调用 +│ └── wrapper-iframe.js # iframe 包装器 +│ +├── ui/ # UI 模板 +│ └── character-updater-menus.html +│ +└── docs/ # 文档 + ├── script-docs.md # 脚本文档 + ├── LICENSE.md # 许可证 + ├── COPYRIGHT # 版权信息 + └── NOTICE # 声明 +``` + +## 📝 模块组织规则 + +- **单文件模块**:直接放在 `modules/` 目录下 +- **多文件模块**:创建子目录,包含相关的 JS、HTML 等文件 +- **桥接模块**:与外部系统交互的独立模块放在 `bridges/` +- **避免使用 `index.js`**:每个模块文件直接命名,不使用 `index.js` + +## 🔄 版本历史 + +- v2.2.2 - 目录结构重构(2025-12-08) + +## 📄 许可证 + +详见 `docs/LICENSE.md` diff --git a/bridges/call-generate-service.js b/bridges/call-generate-service.js index 71b4b4d..053bc32 100644 --- a/bridges/call-generate-service.js +++ b/bridges/call-generate-service.js @@ -4,1359 +4,1359 @@ import { ChatCompletionService } from "../../../../custom-request.js"; import { eventSource, event_types } from "../../../../../script.js"; import { getContext } from "../../../../st-context.js"; import { xbLog } from "../core/debug-core.js"; - -const SOURCE_TAG = 'xiaobaix-host'; - -const POSITIONS = Object.freeze({ BEFORE_PROMPT: 'BEFORE_PROMPT', IN_PROMPT: 'IN_PROMPT', IN_CHAT: 'IN_CHAT', AFTER_COMPONENT: 'AFTER_COMPONENT' }); -const KNOWN_KEYS = Object.freeze(new Set([ - 'main', 'chatHistory', 'worldInfo', 'worldInfoBefore', 'worldInfoAfter', - 'charDescription', 'charPersonality', 'scenario', 'personaDescription', - 'dialogueExamples', 'authorsNote', 'vectorsMemory', 'vectorsDataBank', - 'smartContext', 'jailbreak', 'nsfw', 'summary', 'bias', 'impersonate', 'quietPrompt', -])); - -// @ts-nocheck -class CallGenerateService { - constructor() { - /** @type {Map} */ - this.sessions = new Map(); - this._toggleBusy = false; - this._lastToggleSnapshot = null; - this._toggleQueue = Promise.resolve(); - } - - // ===== 通用错误处理 ===== - normalizeError(err, fallbackCode = 'API_ERROR', details = null) { - try { - if (!err) return { code: fallbackCode, message: 'Unknown error', details }; - if (typeof err === 'string') return { code: fallbackCode, message: err, details }; - const msg = err?.message || String(err); - // Map known cases - if (msg === 'INVALID_OPTIONS') return { code: 'INVALID_OPTIONS', message: 'Invalid options', details }; - if (msg === 'MISSING_MESSAGES') return { code: 'MISSING_MESSAGES', message: 'Missing messages', details }; - if (msg === 'INVALID_COMPONENT_REF') return { code: 'INVALID_COMPONENT_REF', message: 'Invalid component reference', details }; - if (msg === 'AMBIGUOUS_COMPONENT_NAME') return { code: 'AMBIGUOUS_COMPONENT_NAME', message: 'Ambiguous component name', details }; - if (msg === 'Unsupported provider') return { code: 'PROVIDER_UNSUPPORTED', message: msg, details }; - if (err?.name === 'AbortError') return { code: 'CANCELLED', message: 'Request cancelled', details }; - return { code: fallbackCode, message: msg, details }; - } catch { - return { code: fallbackCode, message: 'Error serialization failed', details }; - } - } - - sendError(sourceWindow, requestId, streamingEnabled, err, fallbackCode = 'API_ERROR', details = null) { - const e = this.normalizeError(err, fallbackCode, details); - const type = streamingEnabled ? 'generateStreamError' : 'generateError'; - try { sourceWindow?.postMessage({ source: SOURCE_TAG, type, id: requestId, error: e }, '*'); } catch {} - } - - /** - * @param {string|undefined} rawId - * @returns {string} - */ - normalizeSessionId(rawId) { - if (!rawId) return 'xb1'; - const m = String(rawId).match(/^xb(\d{1,2})$/i); - if (m) { - const n = Math.max(1, Math.min(10, Number(m[1]) || 1)); - return `xb${n}`; - } - const n = Math.max(1, Math.min(10, parseInt(String(rawId), 10) || 1)); - return `xb${n}`; - } - - /** - * @param {string} sessionId - */ - ensureSession(sessionId) { - const id = this.normalizeSessionId(sessionId); - if (!this.sessions.has(id)) { - this.sessions.set(id, { - id, - abortController: new AbortController(), - accumulated: '', - startedAt: Date.now(), - }); - } - return this.sessions.get(id); - } - - /** - * 选项校验(宽松)。 - * 支持仅 injections 或仅 userInput 构建场景。 - * @param {Object} options - * @throws {Error} INVALID_OPTIONS 当 options 非对象 - */ - validateOptions(options) { - if (!options || typeof options !== 'object') throw new Error('INVALID_OPTIONS'); - // 允许仅凭 injections 或 userInput 构建 - const hasComponents = options.components && Array.isArray(options.components.list); - const hasInjections = Array.isArray(options.injections) && options.injections.length > 0; - const hasUserInput = typeof options.userInput === 'string' && options.userInput.length >= 0; - if (!hasComponents && !hasInjections && !hasUserInput) { - // 仍允许空配置,但会构建空 + userInput - return; - } - } - - /** - * @param {string} provider - */ - mapProviderToSource(provider) { - const p = String(provider || '').toLowerCase(); - const map = { - openai: chat_completion_sources.OPENAI, - claude: chat_completion_sources.CLAUDE, - gemini: chat_completion_sources.MAKERSUITE, - google: chat_completion_sources.MAKERSUITE, - vertexai: chat_completion_sources.VERTEXAI, - cohere: chat_completion_sources.COHERE, - deepseek: chat_completion_sources.DEEPSEEK, - xai: chat_completion_sources.XAI, - groq: chat_completion_sources.GROQ, - openrouter: chat_completion_sources.OPENROUTER, - custom: chat_completion_sources.CUSTOM, - }; - return map[p] || null; - } - - /** - * 解析 API 与模型的继承/覆写,并注入代理/自定义地址 - * @param {any} api - */ - resolveApiConfig(api) { - const inherit = api?.inherit !== false; - let source = oai_settings?.chat_completion_source; - let model = getChatCompletionModel ? getChatCompletionModel() : undefined; - let overrides = api?.overrides || {}; - - if (!inherit) { - if (api?.provider) source = this.mapProviderToSource(api.provider); - if (api?.model) model = api.model; - } else { - if (overrides?.provider) source = this.mapProviderToSource(overrides.provider); - if (overrides?.model) model = overrides.model; - } - - if (!source) throw new Error(`Unsupported provider`); - if (!model) throw new Error('Model not specified'); - - const temperature = inherit ? Number(oai_settings?.temp_openai ?? '') : undefined; - const max_tokens = inherit ? (Number(oai_settings?.openai_max_tokens ?? 0) || 1024) : undefined; - const top_p = inherit ? Number(oai_settings?.top_p_openai ?? '') : undefined; - const frequency_penalty = inherit ? Number(oai_settings?.freq_pen_openai ?? '') : undefined; - const presence_penalty = inherit ? Number(oai_settings?.pres_pen_openai ?? '') : undefined; - - const resolved = { - chat_completion_source: source, - model, - temperature, - max_tokens, - top_p, - frequency_penalty, - presence_penalty, - // 代理/自定义地址占位 - reverse_proxy: undefined, - proxy_password: undefined, - custom_url: undefined, - custom_include_body: undefined, - custom_exclude_body: undefined, - custom_include_headers: undefined, - }; - - // 继承代理/自定义配置 - if (inherit) { - const proxySupported = new Set([ - chat_completion_sources.CLAUDE, - chat_completion_sources.OPENAI, - chat_completion_sources.MISTRALAI, - chat_completion_sources.MAKERSUITE, - chat_completion_sources.VERTEXAI, - chat_completion_sources.DEEPSEEK, - chat_completion_sources.XAI, - ]); - if (proxySupported.has(source) && oai_settings?.reverse_proxy) { - resolved.reverse_proxy = String(oai_settings.reverse_proxy).replace(/\/?$/, ''); - if (oai_settings?.proxy_password) resolved.proxy_password = String(oai_settings.proxy_password); - } - if (source === chat_completion_sources.CUSTOM) { - if (oai_settings?.custom_url) resolved.custom_url = String(oai_settings.custom_url); - if (oai_settings?.custom_include_body) resolved.custom_include_body = oai_settings.custom_include_body; - if (oai_settings?.custom_exclude_body) resolved.custom_exclude_body = oai_settings.custom_exclude_body; - if (oai_settings?.custom_include_headers) resolved.custom_include_headers = oai_settings.custom_include_headers; - } - } - - // 显式 baseURL 覆写 - const baseURL = overrides?.baseURL || api?.baseURL; - if (baseURL) { - if (resolved.chat_completion_source === chat_completion_sources.CUSTOM) { - resolved.custom_url = String(baseURL); - } else { - resolved.reverse_proxy = String(baseURL).replace(/\/?$/, ''); - } - } - - const ovw = inherit ? (api?.overrides || {}) : api || {}; - ['temperature', 'maxTokens', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'repetitionPenalty', 'stop', 'responseFormat', 'seed'] - .forEach((k) => { - const keyMap = { - maxTokens: 'max_tokens', - topP: 'top_p', - topK: 'top_k', - frequencyPenalty: 'frequency_penalty', - presencePenalty: 'presence_penalty', - repetitionPenalty: 'repetition_penalty', - responseFormat: 'response_format', - }; - const targetKey = keyMap[k] || k; - if (ovw[k] !== undefined) resolved[targetKey] = ovw[k]; - }); - - return resolved; - } - - /** - * @param {any[]} messages - * @param {any} apiCfg - * @param {boolean} stream - */ - buildChatPayload(messages, apiCfg, stream) { - const payload = { - stream: !!stream, - messages, - model: apiCfg.model, - chat_completion_source: apiCfg.chat_completion_source, - max_tokens: apiCfg.max_tokens, - temperature: apiCfg.temperature, - top_p: apiCfg.top_p, - top_k: apiCfg.top_k, - frequency_penalty: apiCfg.frequency_penalty, - presence_penalty: apiCfg.presence_penalty, - repetition_penalty: apiCfg.repetition_penalty, - stop: Array.isArray(apiCfg.stop) ? apiCfg.stop : undefined, - response_format: apiCfg.response_format, - seed: apiCfg.seed, - // 代理/自定义地址透传 - reverse_proxy: apiCfg.reverse_proxy, - proxy_password: apiCfg.proxy_password, - custom_url: apiCfg.custom_url, - custom_include_body: apiCfg.custom_include_body, - custom_exclude_body: apiCfg.custom_exclude_body, - custom_include_headers: apiCfg.custom_include_headers, - }; - return ChatCompletionService.createRequestData(payload); - } - - /** - * @param {Window} target - * @param {string} type - * @param {object} body - */ - postToTarget(target, type, body) { - try { - target?.postMessage({ source: SOURCE_TAG, type, ...body }, '*'); - } catch (e) {} - } - - // ===== ST Prompt 干跑捕获与组件切换 ===== - - _computeEnableIds(includeConfig) { - const ids = new Set(); - if (!includeConfig || typeof includeConfig !== 'object') return ids; - const c = includeConfig; - if (c.chatHistory?.enabled) ids.add('chatHistory'); - if (c.worldInfo?.enabled || c.worldInfo?.beforeHistory || c.worldInfo?.afterHistory) { - if (c.worldInfo?.beforeHistory !== false) ids.add('worldInfoBefore'); - if (c.worldInfo?.afterHistory !== false) ids.add('worldInfoAfter'); - } - if (c.character?.description) ids.add('charDescription'); - if (c.character?.personality) ids.add('charPersonality'); - if (c.character?.scenario) ids.add('scenario'); - if (c.persona?.description) ids.add('personaDescription'); - return ids; - } - - async _withTemporaryPromptToggles(includeConfig, fn) { return await this._withPromptToggle({ includeConfig }, fn); } - - async _capturePromptMessages({ includeConfig = null, quietText = '', skipWIAN = false }) { - const ctx = getContext(); - /** @type {any} */ - let capturedData = null; - const listener = (data) => { - if (data && typeof data === 'object' && Array.isArray(data.prompt)) { - capturedData = { ...data, prompt: data.prompt.slice() }; - } else if (Array.isArray(data)) { - capturedData = data.slice(); - } - }; - eventSource.on(event_types.GENERATE_AFTER_DATA, listener); - try { - const run = async () => { - await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); - }; - if (includeConfig) { - await this._withTemporaryPromptToggles(includeConfig, run); - } else { - await run(); - } - } finally { - eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); - } - if (!capturedData) return []; - if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); - if (Array.isArray(capturedData)) return capturedData.slice(); - return []; - } - - /** 使用 identifier 集合进行临时启停捕获 */ - async _withPromptEnabledSet(enableSet, fn) { return await this._withPromptToggle({ enableSet }, fn); } - - /** 统一启停切换:支持 includeConfig(标识集)或 enableSet(组件键集合) */ - async _withPromptToggle({ includeConfig = null, enableSet = null } = {}, fn) { - if (!promptManager || typeof promptManager.getPromptOrderForCharacter !== 'function') { - return await fn(); - } - // 使用队列保证串行执行,避免忙等 - const runExclusive = async () => { - this._toggleBusy = true; - let snapshot = []; - try { - const pm = promptManager; - const activeChar = pm?.activeCharacter ?? null; - const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; - snapshot = order.map(e => ({ identifier: e.identifier, enabled: !!e.enabled })); - this._lastToggleSnapshot = snapshot.map(s => ({ ...s })); - - if (includeConfig) { - const enableIds = this._computeEnableIds(includeConfig); - order.forEach(e => { e.enabled = enableIds.has(e.identifier); }); - } else if (enableSet) { - const allow = enableSet instanceof Set ? enableSet : new Set(enableSet); - order.forEach(e => { - let ok = false; - for (const k of allow) { if (this._identifierMatchesKey(e.identifier, k)) { ok = true; break; } } - e.enabled = ok; - }); - } - - return await fn(); - } finally { - try { - const pm = promptManager; - const activeChar = pm?.activeCharacter ?? null; - 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 {} - this._toggleBusy = false; - this._lastToggleSnapshot = null; - } - }; - this._toggleQueue = this._toggleQueue.then(runExclusive, runExclusive); - return await this._toggleQueue; - } - - async _captureWithEnabledSet(enableSet, quietText = '', skipWIAN = false) { - const ctx = getContext(); - /** @type {any} */ - let capturedData = null; - const listener = (data) => { - if (data && typeof data === 'object' && Array.isArray(data.prompt)) { - capturedData = { ...data, prompt: data.prompt.slice() }; - } else if (Array.isArray(data)) { - capturedData = data.slice(); - } - }; - eventSource.on(event_types.GENERATE_AFTER_DATA, listener); - try { - await this._withPromptToggle({ enableSet }, async () => { - await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); - }); - } finally { - eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); - } - if (!capturedData) return []; - if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); - if (Array.isArray(capturedData)) return capturedData.slice(); - return []; - } - - // ===== 工具函数:组件与消息辅助 ===== - - /** - * 获取消息的 component key(用于匹配与排序)。 - * chatHistory-* 归并为 chatHistory;dialogueExamples x-y 归并为 dialogueExamples。 - * @param {string} identifier - * @returns {string} - */ - _getComponentKeyFromIdentifier(identifier) { - const id = String(identifier || ''); - if (id.startsWith('chatHistory')) return 'chatHistory'; - if (id.startsWith('dialogueExamples')) return 'dialogueExamples'; - return id; - } - - /** - * 判断具体 identifier 是否匹配某组件 key(处理聚合键)。 - * @param {string} identifier - * @param {string} key - * @returns {boolean} - */ - _identifierMatchesKey(identifier, key) { - const id = String(identifier || ''); - const k = String(key || ''); - if (!k || !id) return false; - if (k === 'dialogueExamples') return id.startsWith('dialogueExamples'); - if (k === 'worldInfo') return id === 'worldInfoBefore' || id === 'worldInfoAfter'; - if (k === 'chatHistory') return id === 'chatHistory' || id.startsWith('chatHistory'); - return id === k; - } - - /** 将组件键映射到创建锚点与角色,并生成稳定 identifier */ - _mapCreateAnchorForKey(key) { - const k = String(key || ''); - const sys = { position: POSITIONS.IN_PROMPT, role: 'system' }; - const asst = { position: POSITIONS.IN_PROMPT, role: 'assistant' }; - if (k === 'bias') return { ...asst, identifier: 'bias' }; - if (k === 'worldInfo' || k === 'worldInfoBefore') return { ...sys, identifier: 'worldInfoBefore' }; - if (k === 'worldInfoAfter') return { ...sys, identifier: 'worldInfoAfter' }; - if (k === 'charDescription') return { ...sys, identifier: 'charDescription' }; - if (k === 'charPersonality') return { ...sys, identifier: 'charPersonality' }; - if (k === 'scenario') return { ...sys, identifier: 'scenario' }; - if (k === 'personaDescription') return { ...sys, identifier: 'personaDescription' }; - if (k === 'quietPrompt') return { ...sys, identifier: 'quietPrompt' }; - if (k === 'impersonate') return { ...sys, identifier: 'impersonate' }; - if (k === 'authorsNote') return { ...sys, identifier: 'authorsNote' }; - if (k === 'vectorsMemory') return { ...sys, identifier: 'vectorsMemory' }; - if (k === 'vectorsDataBank') return { ...sys, identifier: 'vectorsDataBank' }; - if (k === 'smartContext') return { ...sys, identifier: 'smartContext' }; - if (k === 'summary') return { ...sys, identifier: 'summary' }; - if (k === 'dialogueExamples') return { ...sys, identifier: 'dialogueExamples 0-0' }; - // 默认走 system+IN_PROMPT,并使用 key 作为 identifier - return { ...sys, identifier: k }; - } - - /** - * 将 name 解析为唯一 identifier。 - * 规则: - * 1) 先快速命中已知原生键(直接返回同名 identifier) - * 2) 扫描 PromptManager 的“订单列表”和“集合”,按 name/label/title 精确匹配(大小写不敏感),唯一命中返回其 identifier - * 3) 失败时做一步 sanitize 对比(将非单词字符转为下划线) - * 4) 多命中抛出 AMBIGUOUS_COMPONENT_NAME,零命中返回 null - */ - _resolveNameToIdentifier(rawName) { - try { - const nm = String(rawName || '').trim(); - if (!nm) return null; - - // 1) 原生与常见聚合键的快速命中(支持用户用 name 指代这些键) - if (KNOWN_KEYS.has(nm)) return nm; - - const eq = (a, b) => String(a || '').trim() === String(b || '').trim(); - const sanitize = (s) => String(s || '').replace(/\W/g, '_'); - - const matches = new Set(); - - // 缓存命中 - try { - const nameCache = this._getNameCache(); - if (nameCache.has(nm)) return nameCache.get(nm); - } catch {} - - // 2) 扫描 PromptManager 的订单(显示用) - try { - if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { - const pm = promptManager; - const activeChar = pm?.activeCharacter ?? null; - const order = pm.getPromptOrderForCharacter(activeChar) || []; - for (const e of order) { - const id = e?.identifier; - if (!id) continue; - const candidates = [e?.name, e?.label, e?.title, id].filter(Boolean); - if (candidates.some(x => eq(x, nm))) { - matches.add(id); - continue; - } - } - } - } catch {} - - // 3) 扫描 Prompt 集合(运行期合并后的集合) - try { - if (promptManager && typeof promptManager.getPromptCollection === 'function') { - const pc = promptManager.getPromptCollection(); - const coll = pc?.collection || []; - for (const p of coll) { - const id = p?.identifier; - if (!id) continue; - const candidates = [p?.name, p?.label, p?.title, id].filter(Boolean); - if (candidates.some(x => eq(x, nm))) { - matches.add(id); - continue; - } - } - } - } catch {} - - // 4) 失败时尝试 sanitize 名称与 identifier 的弱匹配 - if (matches.size === 0) { - const nmSan = sanitize(nm); - try { - if (promptManager && typeof promptManager.getPromptCollection === 'function') { - const pc = promptManager.getPromptCollection(); - const coll = pc?.collection || []; - for (const p of coll) { - const id = p?.identifier; - if (!id) continue; - if (sanitize(id) === nmSan) { - matches.add(id); - } - } - } - } catch {} - } - - if (matches.size === 1) { - const id = Array.from(matches)[0]; - try { this._getNameCache().set(nm, id); } catch {} - return id; - } - if (matches.size > 1) { - const err = new Error('AMBIGUOUS_COMPONENT_NAME'); - throw err; - } - return null; - } catch (e) { - // 透传歧义错误,其它情况视为未命中 - if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; - return null; - } - } - - /** - * 解析组件引用 token: - * - 'ALL' → 特殊标记 - * - 'id:identifier' → 直接返回 identifier - * - 'name:xxx' → 通过名称解析为 identifier(大小写敏感) - * - 'xxx' → 先按 name 精确匹配,未命中回退为 identifier - * @param {string} token - * @returns {string|null} - */ - _parseComponentRefToken(token) { - if (!token) return null; - if (typeof token !== 'string') return null; - const raw = token.trim(); - if (!raw) return null; - if (raw.toLowerCase() === 'all') return 'ALL'; - // 特殊模式:仅启用预设中已开启的组件 - if (raw.toLowerCase() === 'all_preon') return 'ALL_PREON'; - if (raw.startsWith('id:')) return raw.slice(3).trim(); - if (raw.startsWith('name:')) { - const nm = raw.slice(5).trim(); - const id = this._resolveNameToIdentifier(nm); - if (id) return id; - const err = new Error('INVALID_COMPONENT_REF'); - throw err; - } - // 默认按 name 精确匹配;未命中则回退当作 identifier 使用 - try { - const byName = this._resolveNameToIdentifier(raw); - if (byName) return byName; - } catch (e) { - if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; - } - return raw; - } - - // ===== 轻量缓存:按 activeCharacter 维度缓存 name→identifier 与 footprint ===== - _getActiveCharacterIdSafe() { - try { - return promptManager?.activeCharacter ?? 'default'; - } catch { return 'default'; } - } - - _getNameCache() { - if (!this._nameCache) this._nameCache = new Map(); - const key = this._getActiveCharacterIdSafe(); - if (!this._nameCache.has(key)) this._nameCache.set(key, new Map()); - return this._nameCache.get(key); - } - - _getFootprintCache() { - if (!this._footprintCache) this._footprintCache = new Map(); - const key = this._getActiveCharacterIdSafe(); - if (!this._footprintCache.has(key)) this._footprintCache.set(key, new Map()); - return this._footprintCache.get(key); - } - - /** - * 解析统一 list:返回三元组 - * - references: 组件引用序列 - * - inlineInjections: 内联注入项(含原始索引) - * - listOverrides: 行内覆写(以组件引用为键) - * @param {Array} list - * @returns {{references:string[], inlineInjections:Array<{index:number,item:any}>, listOverrides:Object}} - */ - _parseUnifiedList(list) { - const references = []; - const inlineInjections = []; - const listOverrides = {}; - for (let i = 0; i < list.length; i++) { - const item = list[i]; - if (typeof item === 'string') { - references.push(item); - continue; - } - if (item && typeof item === 'object' && item.role && item.content) { - inlineInjections.push({ index: i, item }); - continue; - } - if (item && typeof item === 'object') { - const keys = Object.keys(item); - for (const k of keys) { - // k 是组件引用,如 'id:charDescription' / 'scenario' / 'chatHistory' / 'main' - references.push(k); - const cfg = item[k]; - if (cfg && typeof cfg === 'object') { - listOverrides[k] = Object.assign({}, listOverrides[k] || {}, cfg); - } - } - } - } - return { references, inlineInjections, listOverrides }; - } - - /** - * 基于原始 list 计算内联注入的邻接规则,映射到 position/depth。 - * 默认:紧跟前一组件(AFTER_COMPONENT);首项+attach=prev → BEFORE_PROMPT;邻接 chatHistory → IN_CHAT。 - * @param {Array} rawList - * @param {Array<{index:number,item:any}>} inlineInjections - * @returns {Array<{role:string,content:string,position:string,depth?:number,_afterRef?:string}>} - */ - _mapInlineInjectionsUnified(rawList, inlineInjections) { - const result = []; - const getRefAt = (idx, dir) => { - let j = idx + (dir < 0 ? -1 : 1); - while (j >= 0 && j < rawList.length) { - const it = rawList[j]; - if (typeof it === 'string') { - const token = this._parseComponentRefToken(it); - if (token && token !== 'ALL') return token; - } else if (it && typeof it === 'object') { - if (it.role && it.content) { - // inline injection, skip - } else { - const ks = Object.keys(it); - if (ks.length) { - const tk = this._parseComponentRefToken(ks[0]); - if (tk) return tk; - } - } - } - j += (dir < 0 ? -1 : 1); - } - return null; - }; - for (const { index, item } of inlineInjections) { - const prevRef = getRefAt(index, -1); - const nextRef = getRefAt(index, +1); - const attach = item.attach === 'prev' || item.attach === 'next' ? item.attach : 'auto'; - // 显式 position 优先 - if (item.position && typeof item.position === 'string') { - result.push({ role: item.role, content: item.content, position: item.position, depth: item.depth || 0 }); - continue; - } - // 有前邻组件 → 默认插到该组件之后(满足示例:位于 charDescription 之后、main 之前) - if (prevRef) { - result.push({ role: item.role, content: item.content, position: POSITIONS.AFTER_COMPONENT, _afterRef: prevRef }); - continue; - } - if (index === 0 && attach === 'prev') { - result.push({ role: item.role, content: item.content, position: POSITIONS.BEFORE_PROMPT }); - continue; - } - if (prevRef === 'chatHistory' || nextRef === 'chatHistory') { - result.push({ role: item.role, content: item.content, position: POSITIONS.IN_CHAT, depth: 0, _attach: attach === 'prev' ? 'before' : 'after' }); - continue; - } - result.push({ role: item.role, content: item.content, position: POSITIONS.IN_PROMPT }); - } - return result; - } - - /** - * 根据组件集合过滤消息(当 list 不含 ALL)。 - * @param {Array} messages - * @param {Set} wantedKeys - * @returns {Array} - */ - _filterMessagesByComponents(messages, wantedKeys) { - if (!wantedKeys || !wantedKeys.size) return []; - return messages.filter(m => wantedKeys.has(this._getComponentKeyFromIdentifier(m?.identifier))); - } - - /** 稳定重排:对目标子集按给定顺序排序,其他保持相对不变 */ - _stableReorderSubset(messages, orderedKeys) { - if (!Array.isArray(messages) || !orderedKeys || !orderedKeys.length) return messages; - const orderIndex = new Map(); - orderedKeys.forEach((k, i) => orderIndex.set(k, i)); - // 提取目标子集的元素与其原索引 - const targetIndices = []; - const targetMessages = []; - messages.forEach((m, idx) => { - const key = this._getComponentKeyFromIdentifier(m?.identifier); - if (orderIndex.has(key)) { - targetIndices.push(idx); - targetMessages.push({ m, ord: orderIndex.get(key) }); - } - }); - if (!targetIndices.length) return messages; - // 对目标子集按 ord 稳定排序 - targetMessages.sort((a, b) => a.ord - b.ord); - // 将排序后的目标消息放回原有“子集槽位”,非目标元素完全不动 - const out = messages.slice(); - for (let i = 0; i < targetIndices.length; i++) { - out[targetIndices[i]] = targetMessages[i].m; - } - return out; - } - - // ===== 缺失 identifier 的兜底标注 ===== - _normalizeText(s) { - return String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); - } - - _stripNamePrefix(s) { - return String(s || '').replace(/^\s*[^:]{1,32}:\s*/, ''); - } - - _normStrip(s) { return this._normalizeText(this._stripNamePrefix(s)); } - - _createIsFromChat() { - try { - const ctx = getContext(); - const chatArr = Array.isArray(ctx?.chat) ? ctx.chat : []; - const chatNorms = chatArr.map(m => this._normStrip(m?.mes)).filter(Boolean); - const chatSet = new Set(chatNorms); - return (content) => { - const n = this._normStrip(content); - if (!n) return false; - if (chatSet.has(n)) return true; - for (const c of chatNorms) { - const a = n.length, b = c.length; - const minL = Math.min(a, b), maxL = Math.max(a, b); - if (minL < 20) continue; - if (((a >= b && n.includes(c)) || (b >= a && c.includes(n))) && minL / maxL >= 0.8) return true; - } - return false; - }; - } catch { - return () => false; - } - } - - async _annotateIdentifiersIfMissing(messages, targetKeys) { - const arr = Array.isArray(messages) ? messages.map(m => ({ ...m })) : []; - if (!arr.length) return arr; - const hasIdentifier = arr.some(m => typeof m?.identifier === 'string' && m.identifier); - // 标注 chatHistory:依据 role + 来源判断 - const isFromChat = this._createIsFromChat(); - for (const m of arr) { - if (!m?.identifier && (m?.role === 'user' || m?.role === 'assistant') && isFromChat(m.content)) { - m.identifier = 'chatHistory-annotated'; - } - } - // 即使部分已有 identifier,也继续尝试为缺失者做 footprint 标注 - // 若仍缺失,按目标 keys 单独捕获来反向标注 - const keys = Array.from(new Set((Array.isArray(targetKeys) ? targetKeys : []).filter(Boolean))); - if (!keys.length) return arr; - const footprint = new Map(); // key -> Set of norm strings - for (const key of keys) { - try { - if (key === 'chatHistory') continue; // 已在上面标注 - // footprint 缓存命中 - const fpCache = this._getFootprintCache(); - if (fpCache.has(key)) { - footprint.set(key, fpCache.get(key)); - } else { - 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 {} - } - } catch {} - } - for (const m of arr) { - if (m?.identifier) continue; - const sig = `[${m?.role}] ${this._normStrip(m?.content)}`; - for (const [key, set] of footprint.entries()) { - if (set.has(sig)) { m.identifier = key; break; } - } - } - return arr; - } - - /** 覆写:通用组件 disable/replace(文本级),不影响采样参数 */ - _applyGeneralOverrides(messages, overridesByComponent) { - if (!overridesByComponent) return messages; - let out = messages.slice(); - for (const ref in overridesByComponent) { - if (!Object.prototype.hasOwnProperty.call(overridesByComponent, ref)) continue; - const cfg = overridesByComponent[ref]; - if (!cfg || typeof cfg !== 'object') continue; - const key = this._parseComponentRefToken(ref); - if (!key) continue; - if (key === 'chatHistory') continue; // 历史专属逻辑另行处理 - const disable = !!cfg.disable; - const replace = typeof cfg.replace === 'string' ? cfg.replace : null; - if (disable) { - out = out.filter(m => this._getComponentKeyFromIdentifier(m?.identifier) !== key); - continue; - } - if (replace != null) { - out = out.map(m => this._getComponentKeyFromIdentifier(m?.identifier) === key ? { ...m, content: replace } : m); - } - } - return out; - } - - /** 仅对 chatHistory 应用 selector/replaceAll/replace */ - _applyChatHistoryOverride(messages, historyCfg) { - if (!historyCfg) return messages; - const all = messages.slice(); - const indexes = []; - for (let i = 0; i < all.length; i++) { - const m = all[i]; - if (this._getComponentKeyFromIdentifier(m?.identifier) === 'chatHistory') indexes.push(i); - } - if (indexes.length === 0) return messages; - if (historyCfg.disable) { - // 直接移除全部历史 - return all.filter((m, idx) => !indexes.includes(idx)); - } - const history = indexes.map(i => all[i]); - - // selector 过滤 - let selected = history.slice(); - if (historyCfg.selector) { - // 在历史子集上应用 selector - selected = this.applyChatHistorySelector(history, historyCfg.selector); - } - - // 替换逻辑 - let replaced = selected.slice(); - if (historyCfg.replaceAll && Array.isArray(historyCfg.with)) { - replaced = (historyCfg.with || []).map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replaceAll-${idx}` })); - } - if (Array.isArray(historyCfg.replace)) { - // 在 replaced 上按顺序执行多段替换 - for (const step of historyCfg.replace) { - const withArr = Array.isArray(step?.with) ? step.with : []; - const newMsgs = withArr.map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replace-${Date.now()}-${idx}` })); - let indices = []; - if (step?.indices?.values && Array.isArray(step.indices.values) && step.indices.values.length) { - const n = replaced.length; - indices = step.indices.values.map(v0 => { - let v = Number(v0); - if (Number.isNaN(v)) return -1; - if (v < 0) v = n + v; - return (v >= 0 && v < n) ? v : -1; - }).filter(v => v >= 0); - } else if (step?.range && (step.range.start !== undefined || step.range.end !== undefined)) { - let { start = 0, end = replaced.length - 1 } = step.range; - const n = replaced.length; - start = Number(start); end = Number(end); - if (Number.isNaN(start)) start = 0; - if (Number.isNaN(end)) end = n - 1; - if (start < 0) start = n + start; - if (end < 0) end = n + end; - start = Math.max(0, start); end = Math.min(n - 1, end); - if (start <= end) indices = Array.from({ length: end - start + 1 }, (_, k) => start + k); - } else if (step?.last != null) { - const k = Math.max(0, Number(step.last) || 0); - const n = replaced.length; - indices = k > 0 ? Array.from({ length: Math.min(k, n) }, (_, j) => n - k + j) : []; - } - if (indices.length) { - // 按出现顺序处理:先删除这些索引,再按同位置插入(采用最小索引处插入) - const set = new Set(indices); - const kept = replaced.filter((_, idx) => !set.has(idx)); - const insertAt = Math.min(...indices); - replaced = kept.slice(0, insertAt).concat(newMsgs).concat(kept.slice(insertAt)); - } - } - } - - // 将 replaced 合并回全量:找到历史的第一个索引,替换整个历史窗口 - const first = Math.min(...indexes); - const last = Math.max(...indexes); - const before = all.slice(0, first); - const after = all.slice(last + 1); - return before.concat(replaced).concat(after); - } - - /** 将高级 injections 应用到 messages */ - _applyAdvancedInjections(messages, injections = []) { - if (!Array.isArray(injections) || injections.length === 0) return messages; - const out = messages.slice(); - // 计算 chatHistory 边界 - const historyIdx = []; - for (let i = 0; i < out.length; i++) if (this._getComponentKeyFromIdentifier(out[i]?.identifier) === 'chatHistory') historyIdx.push(i); - const hasHistory = historyIdx.length > 0; - const historyStart = hasHistory ? Math.min(...historyIdx) : -1; - const historyEnd = hasHistory ? Math.max(...historyIdx) : -1; - for (const inj of injections) { - const role = inj?.role; const content = inj?.content; - if (!role || typeof content !== 'string') continue; - const forcedId = inj && typeof inj.identifier === 'string' && inj.identifier.trim() ? String(inj.identifier).trim() : null; - const msg = { role, content, identifier: forcedId || `injection-${inj.position || POSITIONS.IN_PROMPT}-${Date.now()}-${Math.random().toString(36).slice(2)}` }; - if (inj.position === POSITIONS.BEFORE_PROMPT) { - out.splice(0, 0, msg); - continue; - } - if (inj.position === POSITIONS.AFTER_COMPONENT) { - const ref = inj._afterRef || null; - let inserted = false; - if (ref) { - for (let i = out.length - 1; i >= 0; i--) { - const id = out[i]?.identifier; - if (this._identifierMatchesKey(id, ref) || this._getComponentKeyFromIdentifier(id) === ref) { - out.splice(i + 1, 0, msg); - inserted = true; break; - } - } - } - if (!inserted) { - // 回退同 IN_PROMPT - if (hasHistory) { - const depth = Math.max(0, Number(inj.depth) || 0); - const insertPos = Math.max(0, historyStart - depth); - out.splice(insertPos, 0, msg); - } else { - out.splice(0, 0, msg); - } - } - continue; - } - if (inj.position === POSITIONS.IN_CHAT && hasHistory) { - // depth=0 → 历史末尾后;depth>0 → 进入历史内部; - const depth = Math.max(0, Number(inj.depth) || 0); - if (inj._attach === 'before') { - const insertPos = Math.max(historyStart - depth, 0); - out.splice(insertPos, 0, msg); - } else { - const insertPos = Math.min(out.length, historyEnd + 1 - depth); - out.splice(Math.max(historyStart, insertPos), 0, msg); - } - continue; - } - // IN_PROMPT 或无历史:在 chatHistory 之前插入,否则置顶后 - if (hasHistory) { - const depth = Math.max(0, Number(inj.depth) || 0); - const insertPos = Math.max(0, historyStart - depth); - out.splice(insertPos, 0, msg); - } else { - out.splice(0, 0, msg); - } - } - return out; - } - - _mergeMessages(baseMessages, extraMessages) { - const out = []; - const seen = new Set(); - const norm = (s) => String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); - const push = (m) => { - if (!m || !m.content) return; - const key = `${m.role}:${norm(m.content)}`; - if (seen.has(key)) return; - seen.add(key); - out.push({ role: m.role, content: m.content }); - }; - baseMessages.forEach(push); - (extraMessages || []).forEach(push); - return out; - } - - _splitMessagesForHistoryOps(messages) { - // history: user/assistant; systemOther: 其余 - const history = []; - const systemOther = []; - for (const m of messages) { - if (!m || typeof m.content !== 'string') continue; - if (m.role === 'user' || m.role === 'assistant') history.push(m); - else systemOther.push(m); - } - return { history, systemOther }; - } - - _applyRolesFilter(list, rolesCfg) { - if (!rolesCfg || (!rolesCfg.include && !rolesCfg.exclude)) return list; - const inc = Array.isArray(rolesCfg.include) && rolesCfg.include.length ? new Set(rolesCfg.include) : null; - const exc = Array.isArray(rolesCfg.exclude) && rolesCfg.exclude.length ? new Set(rolesCfg.exclude) : null; - return list.filter(m => { - const r = m.role; - if (inc && !inc.has(r)) return false; - if (exc && exc.has(r)) return false; - return true; - }); - } - - _applyContentFilter(list, filterCfg) { - if (!filterCfg) return list; - const { contains, regex, fromUserNames, beforeTs, afterTs } = filterCfg; - let out = list.slice(); - if (contains) { - const needles = Array.isArray(contains) ? contains : [contains]; - out = out.filter(m => needles.some(k => String(m.content).includes(String(k)))); - } - if (regex) { - try { - const re = new RegExp(regex); - out = out.filter(m => re.test(String(m.content))); - } catch {} - } - if (fromUserNames && fromUserNames.length) { - // 仅当 messages 中附带 name 时生效;否则忽略 - out = out.filter(m => !m.name || fromUserNames.includes(m.name)); - } - // 时间戳过滤需要原始数据支持,这里忽略(占位) - return out; - } - - _applyAnchorWindow(list, anchorCfg) { - if (!anchorCfg || !list.length) return list; - const { anchor = 'lastUser', before = 0, after = 0 } = anchorCfg; - // 找到锚点索引 - let idx = -1; - if (anchor === 'lastUser') { - for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'user') { idx = i; break; } - } else if (anchor === 'lastAssistant') { - for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'assistant') { idx = i; break; } - } else if (anchor === 'lastSystem') { - for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'system') { idx = i; break; } - } - if (idx === -1) return list; - const start = Math.max(0, idx - Number(before || 0)); - const end = Math.min(list.length - 1, idx + Number(after || 0)); - return list.slice(start, end + 1); - } - - _applyIndicesRange(list, selector) { - const idxBase = selector?.indexBase === 'all' ? 'all' : 'history'; - let result = list.slice(); - // indices 优先 - if (Array.isArray(selector?.indices?.values) && selector.indices.values.length) { - const vals = selector.indices.values; - const picked = []; - const n = list.length; - for (const v0 of vals) { - let v = Number(v0); - if (Number.isNaN(v)) continue; - if (v < 0) v = n + v; // 负索引 - if (v >= 0 && v < n) picked.push(list[v]); - } - result = picked; - return result; - } - if (selector?.range && (selector.range.start !== undefined || selector.range.end !== undefined)) { - let { start = 0, end = list.length - 1 } = selector.range; - const n = list.length; - start = Number(start); end = Number(end); - if (Number.isNaN(start)) start = 0; - if (Number.isNaN(end)) end = n - 1; - if (start < 0) start = n + start; - if (end < 0) end = n + end; - start = Math.max(0, start); end = Math.min(n - 1, end); - if (start > end) return []; - return list.slice(start, end + 1); - } - if (selector?.last !== undefined && selector.last !== null) { - const k = Math.max(0, Number(selector.last) || 0); - if (k === 0) return []; - const n = list.length; - return list.slice(Math.max(0, n - k)); - } - return result; - } - - _applyTakeEvery(list, step) { - const s = Math.max(1, Number(step) || 1); - if (s === 1) return list; - const out = []; - for (let i = 0; i < list.length; i += s) out.push(list[i]); - return out; - } - - _applyLimit(list, limitCfg) { - if (!limitCfg) return list; - // 仅实现 count,tokenBudget 预留 - const count = Number(limitCfg.count || 0); - if (count > 0 && list.length > count) { - const how = limitCfg.truncateStrategy || 'last'; - if (how === 'first') return list.slice(0, count); - if (how === 'middle') { - const left = Math.floor(count / 2); - const right = count - left; - return list.slice(0, left).concat(list.slice(-right)); - } - if (how === 'even') { - const step = Math.ceil(list.length / count); - const out = []; - for (let i = 0; i < list.length && out.length < count; i += step) out.push(list[i]); - return out; - } - // default: 'last' → 取末尾 - return list.slice(-count); - } - return list; - } - - applyChatHistorySelector(messages, selector) { - if (!selector || !Array.isArray(messages) || !messages.length) return messages; - const { history, systemOther } = this._splitMessagesForHistoryOps(messages); - let list = history; - // roles/filter/anchor → indices/range/last → takeEvery → limit - list = this._applyRolesFilter(list, selector.roles); - list = this._applyContentFilter(list, selector.filter); - list = this._applyAnchorWindow(list, selector.anchorWindow); - list = this._applyIndicesRange(list, selector); - list = this._applyTakeEvery(list, selector.takeEvery); - list = this._applyLimit(list, selector.limit || (selector.last ? { count: Number(selector.last) } : null)); - // 合并非历史部分 - return systemOther.concat(list); - } - - // ===== 发送实现(构建后的统一发送) ===== - - async _sendMessages(messages, options, requestId, sourceWindow) { - const sessionId = this.normalizeSessionId(options?.session?.id || 'xb1'); - const session = this.ensureSession(sessionId); - const streamingEnabled = options?.streaming?.enabled !== false; // 默认开 - const apiCfg = this.resolveApiConfig(options?.api || {}); - const payload = this.buildChatPayload(messages, apiCfg, streamingEnabled); - - try { - const shouldExport = !!(options?.debug?.enabled || options?.debug?.exportPrompt); - const already = options?.debug?._exported === true; - if (shouldExport && !already) { - this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: (messages || []).map(m => ({ role: m.role, content: m.content })) }); - } - - if (streamingEnabled) { - this.postToTarget(sourceWindow, 'generateStreamStart', { id: requestId, sessionId }); - const streamFn = await ChatCompletionService.sendRequest(payload, false, session.abortController.signal); - let last = ''; - const generator = typeof streamFn === 'function' ? streamFn() : null; - for await (const { text } of (generator || [])) { - const chunk = text.slice(last.length); - last = text; - session.accumulated = text; - this.postToTarget(sourceWindow, 'generateStreamChunk', { id: requestId, chunk, accumulated: text, metadata: {} }); - } - const result = { - success: true, - result: session.accumulated, - sessionId, - metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, - }; - this.postToTarget(sourceWindow, 'generateStreamComplete', { id: requestId, result }); - return result; - } else { - const extracted = await ChatCompletionService.sendRequest(payload, true, session.abortController.signal); - const result = { - success: true, - result: String((extracted && extracted.content) || ''), - sessionId, - metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, - }; - this.postToTarget(sourceWindow, 'generateResult', { id: requestId, result }); - return result; - } - } catch (err) { - this.sendError(sourceWindow, requestId, streamingEnabled, err); - return null; - } - } - - // ===== 主流程 ===== - async handleRequestInternal(options, requestId, sourceWindow) { - // 1) 校验 - this.validateOptions(options); - - // 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 = []; - 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 {} - 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 {} - 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) 依据策略计算启用集合与顺序 - const annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []); - 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 }); - - // 9) 发送 - return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow); - } - - _applyOrderingStrategy(messages, baseStrategy, orderedRefs, unorderedKeys) { - let out = messages.slice(); - if (baseStrategy === 'SUBSET') { - const want = new Set(orderedRefs); - out = this._filterMessagesByComponents(out, want); - } else if ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') && orderedRefs.length) { - const targets = orderedRefs.filter(k => !unorderedKeys.has(k)); - if (targets.length) out = this._stableReorderSubset(out, targets); - } - return out; - } - - _applyInlineOverrides(messages, byComp) { - let out = messages.slice(); - if (!byComp) return out; - out = this._applyGeneralOverrides(out, byComp); - const ensureInjections = []; - for (const ref in byComp) { - if (!Object.prototype.hasOwnProperty.call(byComp, ref)) continue; - const key = this._parseComponentRefToken(ref); - if (!key || key === 'chatHistory') continue; - const cfg = byComp[ref]; - if (!cfg || typeof cfg.replace !== 'string') continue; - const exists = out.some(m => this._identifierMatchesKey(m?.identifier, key) || this._getComponentKeyFromIdentifier(m?.identifier) === key); - if (exists) continue; - const map = this._mapCreateAnchorForKey(key); - ensureInjections.push({ position: map.position, role: map.role, content: cfg.replace, identifier: map.identifier }); - } - if (ensureInjections.length) { - out = this._applyAdvancedInjections(out, ensureInjections); - } - if (byComp['id:chatHistory'] || byComp['chatHistory']) { - const cfg = byComp['id:chatHistory'] || byComp['chatHistory']; - out = this._applyChatHistoryOverride(out, cfg); - } - return out; - } - - _applyAllInjections(messages, inlineMapped, advancedInjections) { - let out = messages.slice(); - if (inlineMapped && inlineMapped.length) { - out = this._applyAdvancedInjections(out, inlineMapped); - } - if (Array.isArray(advancedInjections) && advancedInjections.length) { - out = this._applyAdvancedInjections(out, advancedInjections); - } - return out; - } - - _appendUserInput(messages, userInput) { - const out = messages.slice(); - if (typeof userInput === 'string' && userInput.length >= 0) { - out.push({ role: 'user', content: String(userInput || ''), identifier: 'userInput' }); - } - return out; - } - - _exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug }) { - const exportPrompt = !!(debug?.enabled || debug?.exportPrompt); - if (exportPrompt) this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: working.map(m => ({ role: m.role, content: m.content })) }); - if (debug?.exportBlueprint) { - try { - const bp = { - id: requestId, - components: { strategy: baseStrategy, order: orderedRefs }, - injections: (debug?.injections || []).concat(inlineMapped || []), - overrides: listLevelOverrides || null, - }; - this.postToTarget(sourceWindow, 'blueprint', bp); - } catch {} - } - } - - /** - * 入口:处理 generateRequest(统一入口) - */ + +const SOURCE_TAG = 'xiaobaix-host'; + +const POSITIONS = Object.freeze({ BEFORE_PROMPT: 'BEFORE_PROMPT', IN_PROMPT: 'IN_PROMPT', IN_CHAT: 'IN_CHAT', AFTER_COMPONENT: 'AFTER_COMPONENT' }); +const KNOWN_KEYS = Object.freeze(new Set([ + 'main', 'chatHistory', 'worldInfo', 'worldInfoBefore', 'worldInfoAfter', + 'charDescription', 'charPersonality', 'scenario', 'personaDescription', + 'dialogueExamples', 'authorsNote', 'vectorsMemory', 'vectorsDataBank', + 'smartContext', 'jailbreak', 'nsfw', 'summary', 'bias', 'impersonate', 'quietPrompt', +])); + +// @ts-nocheck +class CallGenerateService { + constructor() { + /** @type {Map} */ + this.sessions = new Map(); + this._toggleBusy = false; + this._lastToggleSnapshot = null; + this._toggleQueue = Promise.resolve(); + } + + // ===== 通用错误处理 ===== + normalizeError(err, fallbackCode = 'API_ERROR', details = null) { + try { + if (!err) return { code: fallbackCode, message: 'Unknown error', details }; + if (typeof err === 'string') return { code: fallbackCode, message: err, details }; + const msg = err?.message || String(err); + // Map known cases + if (msg === 'INVALID_OPTIONS') return { code: 'INVALID_OPTIONS', message: 'Invalid options', details }; + if (msg === 'MISSING_MESSAGES') return { code: 'MISSING_MESSAGES', message: 'Missing messages', details }; + if (msg === 'INVALID_COMPONENT_REF') return { code: 'INVALID_COMPONENT_REF', message: 'Invalid component reference', details }; + if (msg === 'AMBIGUOUS_COMPONENT_NAME') return { code: 'AMBIGUOUS_COMPONENT_NAME', message: 'Ambiguous component name', details }; + if (msg === 'Unsupported provider') return { code: 'PROVIDER_UNSUPPORTED', message: msg, details }; + if (err?.name === 'AbortError') return { code: 'CANCELLED', message: 'Request cancelled', details }; + return { code: fallbackCode, message: msg, details }; + } catch { + return { code: fallbackCode, message: 'Error serialization failed', details }; + } + } + + sendError(sourceWindow, requestId, streamingEnabled, err, fallbackCode = 'API_ERROR', details = null) { + const e = this.normalizeError(err, fallbackCode, details); + const type = streamingEnabled ? 'generateStreamError' : 'generateError'; + try { sourceWindow?.postMessage({ source: SOURCE_TAG, type, id: requestId, error: e }, '*'); } catch {} + } + + /** + * @param {string|undefined} rawId + * @returns {string} + */ + normalizeSessionId(rawId) { + if (!rawId) return 'xb1'; + const m = String(rawId).match(/^xb(\d{1,2})$/i); + if (m) { + const n = Math.max(1, Math.min(10, Number(m[1]) || 1)); + return `xb${n}`; + } + const n = Math.max(1, Math.min(10, parseInt(String(rawId), 10) || 1)); + return `xb${n}`; + } + + /** + * @param {string} sessionId + */ + ensureSession(sessionId) { + const id = this.normalizeSessionId(sessionId); + if (!this.sessions.has(id)) { + this.sessions.set(id, { + id, + abortController: new AbortController(), + accumulated: '', + startedAt: Date.now(), + }); + } + return this.sessions.get(id); + } + + /** + * 选项校验(宽松)。 + * 支持仅 injections 或仅 userInput 构建场景。 + * @param {Object} options + * @throws {Error} INVALID_OPTIONS 当 options 非对象 + */ + validateOptions(options) { + if (!options || typeof options !== 'object') throw new Error('INVALID_OPTIONS'); + // 允许仅凭 injections 或 userInput 构建 + const hasComponents = options.components && Array.isArray(options.components.list); + const hasInjections = Array.isArray(options.injections) && options.injections.length > 0; + const hasUserInput = typeof options.userInput === 'string' && options.userInput.length >= 0; + if (!hasComponents && !hasInjections && !hasUserInput) { + // 仍允许空配置,但会构建空 + userInput + return; + } + } + + /** + * @param {string} provider + */ + mapProviderToSource(provider) { + const p = String(provider || '').toLowerCase(); + const map = { + openai: chat_completion_sources.OPENAI, + claude: chat_completion_sources.CLAUDE, + gemini: chat_completion_sources.MAKERSUITE, + google: chat_completion_sources.MAKERSUITE, + vertexai: chat_completion_sources.VERTEXAI, + cohere: chat_completion_sources.COHERE, + deepseek: chat_completion_sources.DEEPSEEK, + xai: chat_completion_sources.XAI, + groq: chat_completion_sources.GROQ, + openrouter: chat_completion_sources.OPENROUTER, + custom: chat_completion_sources.CUSTOM, + }; + return map[p] || null; + } + + /** + * 解析 API 与模型的继承/覆写,并注入代理/自定义地址 + * @param {any} api + */ + resolveApiConfig(api) { + const inherit = api?.inherit !== false; + let source = oai_settings?.chat_completion_source; + let model = getChatCompletionModel ? getChatCompletionModel() : undefined; + let overrides = api?.overrides || {}; + + if (!inherit) { + if (api?.provider) source = this.mapProviderToSource(api.provider); + if (api?.model) model = api.model; + } else { + if (overrides?.provider) source = this.mapProviderToSource(overrides.provider); + if (overrides?.model) model = overrides.model; + } + + if (!source) throw new Error(`Unsupported provider`); + if (!model) throw new Error('Model not specified'); + + const temperature = inherit ? Number(oai_settings?.temp_openai ?? '') : undefined; + const max_tokens = inherit ? (Number(oai_settings?.openai_max_tokens ?? 0) || 1024) : undefined; + const top_p = inherit ? Number(oai_settings?.top_p_openai ?? '') : undefined; + const frequency_penalty = inherit ? Number(oai_settings?.freq_pen_openai ?? '') : undefined; + const presence_penalty = inherit ? Number(oai_settings?.pres_pen_openai ?? '') : undefined; + + const resolved = { + chat_completion_source: source, + model, + temperature, + max_tokens, + top_p, + frequency_penalty, + presence_penalty, + // 代理/自定义地址占位 + reverse_proxy: undefined, + proxy_password: undefined, + custom_url: undefined, + custom_include_body: undefined, + custom_exclude_body: undefined, + custom_include_headers: undefined, + }; + + // 继承代理/自定义配置 + if (inherit) { + const proxySupported = new Set([ + chat_completion_sources.CLAUDE, + chat_completion_sources.OPENAI, + chat_completion_sources.MISTRALAI, + chat_completion_sources.MAKERSUITE, + chat_completion_sources.VERTEXAI, + chat_completion_sources.DEEPSEEK, + chat_completion_sources.XAI, + ]); + if (proxySupported.has(source) && oai_settings?.reverse_proxy) { + resolved.reverse_proxy = String(oai_settings.reverse_proxy).replace(/\/?$/, ''); + if (oai_settings?.proxy_password) resolved.proxy_password = String(oai_settings.proxy_password); + } + if (source === chat_completion_sources.CUSTOM) { + if (oai_settings?.custom_url) resolved.custom_url = String(oai_settings.custom_url); + if (oai_settings?.custom_include_body) resolved.custom_include_body = oai_settings.custom_include_body; + if (oai_settings?.custom_exclude_body) resolved.custom_exclude_body = oai_settings.custom_exclude_body; + if (oai_settings?.custom_include_headers) resolved.custom_include_headers = oai_settings.custom_include_headers; + } + } + + // 显式 baseURL 覆写 + const baseURL = overrides?.baseURL || api?.baseURL; + if (baseURL) { + if (resolved.chat_completion_source === chat_completion_sources.CUSTOM) { + resolved.custom_url = String(baseURL); + } else { + resolved.reverse_proxy = String(baseURL).replace(/\/?$/, ''); + } + } + + const ovw = inherit ? (api?.overrides || {}) : api || {}; + ['temperature', 'maxTokens', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'repetitionPenalty', 'stop', 'responseFormat', 'seed'] + .forEach((k) => { + const keyMap = { + maxTokens: 'max_tokens', + topP: 'top_p', + topK: 'top_k', + frequencyPenalty: 'frequency_penalty', + presencePenalty: 'presence_penalty', + repetitionPenalty: 'repetition_penalty', + responseFormat: 'response_format', + }; + const targetKey = keyMap[k] || k; + if (ovw[k] !== undefined) resolved[targetKey] = ovw[k]; + }); + + return resolved; + } + + /** + * @param {any[]} messages + * @param {any} apiCfg + * @param {boolean} stream + */ + buildChatPayload(messages, apiCfg, stream) { + const payload = { + stream: !!stream, + messages, + model: apiCfg.model, + chat_completion_source: apiCfg.chat_completion_source, + max_tokens: apiCfg.max_tokens, + temperature: apiCfg.temperature, + top_p: apiCfg.top_p, + top_k: apiCfg.top_k, + frequency_penalty: apiCfg.frequency_penalty, + presence_penalty: apiCfg.presence_penalty, + repetition_penalty: apiCfg.repetition_penalty, + stop: Array.isArray(apiCfg.stop) ? apiCfg.stop : undefined, + response_format: apiCfg.response_format, + seed: apiCfg.seed, + // 代理/自定义地址透传 + reverse_proxy: apiCfg.reverse_proxy, + proxy_password: apiCfg.proxy_password, + custom_url: apiCfg.custom_url, + custom_include_body: apiCfg.custom_include_body, + custom_exclude_body: apiCfg.custom_exclude_body, + custom_include_headers: apiCfg.custom_include_headers, + }; + return ChatCompletionService.createRequestData(payload); + } + + /** + * @param {Window} target + * @param {string} type + * @param {object} body + */ + postToTarget(target, type, body) { + try { + target?.postMessage({ source: SOURCE_TAG, type, ...body }, '*'); + } catch (e) {} + } + + // ===== ST Prompt 干跑捕获与组件切换 ===== + + _computeEnableIds(includeConfig) { + const ids = new Set(); + if (!includeConfig || typeof includeConfig !== 'object') return ids; + const c = includeConfig; + if (c.chatHistory?.enabled) ids.add('chatHistory'); + if (c.worldInfo?.enabled || c.worldInfo?.beforeHistory || c.worldInfo?.afterHistory) { + if (c.worldInfo?.beforeHistory !== false) ids.add('worldInfoBefore'); + if (c.worldInfo?.afterHistory !== false) ids.add('worldInfoAfter'); + } + if (c.character?.description) ids.add('charDescription'); + if (c.character?.personality) ids.add('charPersonality'); + if (c.character?.scenario) ids.add('scenario'); + if (c.persona?.description) ids.add('personaDescription'); + return ids; + } + + async _withTemporaryPromptToggles(includeConfig, fn) { return await this._withPromptToggle({ includeConfig }, fn); } + + async _capturePromptMessages({ includeConfig = null, quietText = '', skipWIAN = false }) { + const ctx = getContext(); + /** @type {any} */ + let capturedData = null; + const listener = (data) => { + if (data && typeof data === 'object' && Array.isArray(data.prompt)) { + capturedData = { ...data, prompt: data.prompt.slice() }; + } else if (Array.isArray(data)) { + capturedData = data.slice(); + } + }; + eventSource.on(event_types.GENERATE_AFTER_DATA, listener); + try { + const run = async () => { + await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); + }; + if (includeConfig) { + await this._withTemporaryPromptToggles(includeConfig, run); + } else { + await run(); + } + } finally { + eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); + } + if (!capturedData) return []; + if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); + if (Array.isArray(capturedData)) return capturedData.slice(); + return []; + } + + /** 使用 identifier 集合进行临时启停捕获 */ + async _withPromptEnabledSet(enableSet, fn) { return await this._withPromptToggle({ enableSet }, fn); } + + /** 统一启停切换:支持 includeConfig(标识集)或 enableSet(组件键集合) */ + async _withPromptToggle({ includeConfig = null, enableSet = null } = {}, fn) { + if (!promptManager || typeof promptManager.getPromptOrderForCharacter !== 'function') { + return await fn(); + } + // 使用队列保证串行执行,避免忙等 + const runExclusive = async () => { + this._toggleBusy = true; + let snapshot = []; + try { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm?.getPromptOrderForCharacter(activeChar) ?? []; + snapshot = order.map(e => ({ identifier: e.identifier, enabled: !!e.enabled })); + this._lastToggleSnapshot = snapshot.map(s => ({ ...s })); + + if (includeConfig) { + const enableIds = this._computeEnableIds(includeConfig); + order.forEach(e => { e.enabled = enableIds.has(e.identifier); }); + } else if (enableSet) { + const allow = enableSet instanceof Set ? enableSet : new Set(enableSet); + order.forEach(e => { + let ok = false; + for (const k of allow) { if (this._identifierMatchesKey(e.identifier, k)) { ok = true; break; } } + e.enabled = ok; + }); + } + + return await fn(); + } finally { + try { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + 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 {} + this._toggleBusy = false; + this._lastToggleSnapshot = null; + } + }; + this._toggleQueue = this._toggleQueue.then(runExclusive, runExclusive); + return await this._toggleQueue; + } + + async _captureWithEnabledSet(enableSet, quietText = '', skipWIAN = false) { + const ctx = getContext(); + /** @type {any} */ + let capturedData = null; + const listener = (data) => { + if (data && typeof data === 'object' && Array.isArray(data.prompt)) { + capturedData = { ...data, prompt: data.prompt.slice() }; + } else if (Array.isArray(data)) { + capturedData = data.slice(); + } + }; + eventSource.on(event_types.GENERATE_AFTER_DATA, listener); + try { + await this._withPromptToggle({ enableSet }, async () => { + await ctx.generate('normal', { quiet_prompt: String(quietText || ''), quietToLoud: false, skipWIAN, force_name2: true }, true); + }); + } finally { + eventSource.removeListener(event_types.GENERATE_AFTER_DATA, listener); + } + if (!capturedData) return []; + if (capturedData && typeof capturedData === 'object' && Array.isArray(capturedData.prompt)) return capturedData.prompt.slice(); + if (Array.isArray(capturedData)) return capturedData.slice(); + return []; + } + + // ===== 工具函数:组件与消息辅助 ===== + + /** + * 获取消息的 component key(用于匹配与排序)。 + * chatHistory-* 归并为 chatHistory;dialogueExamples x-y 归并为 dialogueExamples。 + * @param {string} identifier + * @returns {string} + */ + _getComponentKeyFromIdentifier(identifier) { + const id = String(identifier || ''); + if (id.startsWith('chatHistory')) return 'chatHistory'; + if (id.startsWith('dialogueExamples')) return 'dialogueExamples'; + return id; + } + + /** + * 判断具体 identifier 是否匹配某组件 key(处理聚合键)。 + * @param {string} identifier + * @param {string} key + * @returns {boolean} + */ + _identifierMatchesKey(identifier, key) { + const id = String(identifier || ''); + const k = String(key || ''); + if (!k || !id) return false; + if (k === 'dialogueExamples') return id.startsWith('dialogueExamples'); + if (k === 'worldInfo') return id === 'worldInfoBefore' || id === 'worldInfoAfter'; + if (k === 'chatHistory') return id === 'chatHistory' || id.startsWith('chatHistory'); + return id === k; + } + + /** 将组件键映射到创建锚点与角色,并生成稳定 identifier */ + _mapCreateAnchorForKey(key) { + const k = String(key || ''); + const sys = { position: POSITIONS.IN_PROMPT, role: 'system' }; + const asst = { position: POSITIONS.IN_PROMPT, role: 'assistant' }; + if (k === 'bias') return { ...asst, identifier: 'bias' }; + if (k === 'worldInfo' || k === 'worldInfoBefore') return { ...sys, identifier: 'worldInfoBefore' }; + if (k === 'worldInfoAfter') return { ...sys, identifier: 'worldInfoAfter' }; + if (k === 'charDescription') return { ...sys, identifier: 'charDescription' }; + if (k === 'charPersonality') return { ...sys, identifier: 'charPersonality' }; + if (k === 'scenario') return { ...sys, identifier: 'scenario' }; + if (k === 'personaDescription') return { ...sys, identifier: 'personaDescription' }; + if (k === 'quietPrompt') return { ...sys, identifier: 'quietPrompt' }; + if (k === 'impersonate') return { ...sys, identifier: 'impersonate' }; + if (k === 'authorsNote') return { ...sys, identifier: 'authorsNote' }; + if (k === 'vectorsMemory') return { ...sys, identifier: 'vectorsMemory' }; + if (k === 'vectorsDataBank') return { ...sys, identifier: 'vectorsDataBank' }; + if (k === 'smartContext') return { ...sys, identifier: 'smartContext' }; + if (k === 'summary') return { ...sys, identifier: 'summary' }; + if (k === 'dialogueExamples') return { ...sys, identifier: 'dialogueExamples 0-0' }; + // 默认走 system+IN_PROMPT,并使用 key 作为 identifier + return { ...sys, identifier: k }; + } + + /** + * 将 name 解析为唯一 identifier。 + * 规则: + * 1) 先快速命中已知原生键(直接返回同名 identifier) + * 2) 扫描 PromptManager 的“订单列表”和“集合”,按 name/label/title 精确匹配(大小写不敏感),唯一命中返回其 identifier + * 3) 失败时做一步 sanitize 对比(将非单词字符转为下划线) + * 4) 多命中抛出 AMBIGUOUS_COMPONENT_NAME,零命中返回 null + */ + _resolveNameToIdentifier(rawName) { + try { + const nm = String(rawName || '').trim(); + if (!nm) return null; + + // 1) 原生与常见聚合键的快速命中(支持用户用 name 指代这些键) + if (KNOWN_KEYS.has(nm)) return nm; + + const eq = (a, b) => String(a || '').trim() === String(b || '').trim(); + const sanitize = (s) => String(s || '').replace(/\W/g, '_'); + + const matches = new Set(); + + // 缓存命中 + try { + const nameCache = this._getNameCache(); + if (nameCache.has(nm)) return nameCache.get(nm); + } catch {} + + // 2) 扫描 PromptManager 的订单(显示用) + try { + if (promptManager && typeof promptManager.getPromptOrderForCharacter === 'function') { + const pm = promptManager; + const activeChar = pm?.activeCharacter ?? null; + const order = pm.getPromptOrderForCharacter(activeChar) || []; + for (const e of order) { + const id = e?.identifier; + if (!id) continue; + const candidates = [e?.name, e?.label, e?.title, id].filter(Boolean); + if (candidates.some(x => eq(x, nm))) { + matches.add(id); + continue; + } + } + } + } catch {} + + // 3) 扫描 Prompt 集合(运行期合并后的集合) + try { + if (promptManager && typeof promptManager.getPromptCollection === 'function') { + const pc = promptManager.getPromptCollection(); + const coll = pc?.collection || []; + for (const p of coll) { + const id = p?.identifier; + if (!id) continue; + const candidates = [p?.name, p?.label, p?.title, id].filter(Boolean); + if (candidates.some(x => eq(x, nm))) { + matches.add(id); + continue; + } + } + } + } catch {} + + // 4) 失败时尝试 sanitize 名称与 identifier 的弱匹配 + if (matches.size === 0) { + const nmSan = sanitize(nm); + try { + if (promptManager && typeof promptManager.getPromptCollection === 'function') { + const pc = promptManager.getPromptCollection(); + const coll = pc?.collection || []; + for (const p of coll) { + const id = p?.identifier; + if (!id) continue; + if (sanitize(id) === nmSan) { + matches.add(id); + } + } + } + } catch {} + } + + if (matches.size === 1) { + const id = Array.from(matches)[0]; + try { this._getNameCache().set(nm, id); } catch {} + return id; + } + if (matches.size > 1) { + const err = new Error('AMBIGUOUS_COMPONENT_NAME'); + throw err; + } + return null; + } catch (e) { + // 透传歧义错误,其它情况视为未命中 + if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; + return null; + } + } + + /** + * 解析组件引用 token: + * - 'ALL' → 特殊标记 + * - 'id:identifier' → 直接返回 identifier + * - 'name:xxx' → 通过名称解析为 identifier(大小写敏感) + * - 'xxx' → 先按 name 精确匹配,未命中回退为 identifier + * @param {string} token + * @returns {string|null} + */ + _parseComponentRefToken(token) { + if (!token) return null; + if (typeof token !== 'string') return null; + const raw = token.trim(); + if (!raw) return null; + if (raw.toLowerCase() === 'all') return 'ALL'; + // 特殊模式:仅启用预设中已开启的组件 + if (raw.toLowerCase() === 'all_preon') return 'ALL_PREON'; + if (raw.startsWith('id:')) return raw.slice(3).trim(); + if (raw.startsWith('name:')) { + const nm = raw.slice(5).trim(); + const id = this._resolveNameToIdentifier(nm); + if (id) return id; + const err = new Error('INVALID_COMPONENT_REF'); + throw err; + } + // 默认按 name 精确匹配;未命中则回退当作 identifier 使用 + try { + const byName = this._resolveNameToIdentifier(raw); + if (byName) return byName; + } catch (e) { + if (String(e?.message) === 'AMBIGUOUS_COMPONENT_NAME') throw e; + } + return raw; + } + + // ===== 轻量缓存:按 activeCharacter 维度缓存 name→identifier 与 footprint ===== + _getActiveCharacterIdSafe() { + try { + return promptManager?.activeCharacter ?? 'default'; + } catch { return 'default'; } + } + + _getNameCache() { + if (!this._nameCache) this._nameCache = new Map(); + const key = this._getActiveCharacterIdSafe(); + if (!this._nameCache.has(key)) this._nameCache.set(key, new Map()); + return this._nameCache.get(key); + } + + _getFootprintCache() { + if (!this._footprintCache) this._footprintCache = new Map(); + const key = this._getActiveCharacterIdSafe(); + if (!this._footprintCache.has(key)) this._footprintCache.set(key, new Map()); + return this._footprintCache.get(key); + } + + /** + * 解析统一 list:返回三元组 + * - references: 组件引用序列 + * - inlineInjections: 内联注入项(含原始索引) + * - listOverrides: 行内覆写(以组件引用为键) + * @param {Array} list + * @returns {{references:string[], inlineInjections:Array<{index:number,item:any}>, listOverrides:Object}} + */ + _parseUnifiedList(list) { + const references = []; + const inlineInjections = []; + const listOverrides = {}; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (typeof item === 'string') { + references.push(item); + continue; + } + if (item && typeof item === 'object' && item.role && item.content) { + inlineInjections.push({ index: i, item }); + continue; + } + if (item && typeof item === 'object') { + const keys = Object.keys(item); + for (const k of keys) { + // k 是组件引用,如 'id:charDescription' / 'scenario' / 'chatHistory' / 'main' + references.push(k); + const cfg = item[k]; + if (cfg && typeof cfg === 'object') { + listOverrides[k] = Object.assign({}, listOverrides[k] || {}, cfg); + } + } + } + } + return { references, inlineInjections, listOverrides }; + } + + /** + * 基于原始 list 计算内联注入的邻接规则,映射到 position/depth。 + * 默认:紧跟前一组件(AFTER_COMPONENT);首项+attach=prev → BEFORE_PROMPT;邻接 chatHistory → IN_CHAT。 + * @param {Array} rawList + * @param {Array<{index:number,item:any}>} inlineInjections + * @returns {Array<{role:string,content:string,position:string,depth?:number,_afterRef?:string}>} + */ + _mapInlineInjectionsUnified(rawList, inlineInjections) { + const result = []; + const getRefAt = (idx, dir) => { + let j = idx + (dir < 0 ? -1 : 1); + while (j >= 0 && j < rawList.length) { + const it = rawList[j]; + if (typeof it === 'string') { + const token = this._parseComponentRefToken(it); + if (token && token !== 'ALL') return token; + } else if (it && typeof it === 'object') { + if (it.role && it.content) { + // inline injection, skip + } else { + const ks = Object.keys(it); + if (ks.length) { + const tk = this._parseComponentRefToken(ks[0]); + if (tk) return tk; + } + } + } + j += (dir < 0 ? -1 : 1); + } + return null; + }; + for (const { index, item } of inlineInjections) { + const prevRef = getRefAt(index, -1); + const nextRef = getRefAt(index, +1); + const attach = item.attach === 'prev' || item.attach === 'next' ? item.attach : 'auto'; + // 显式 position 优先 + if (item.position && typeof item.position === 'string') { + result.push({ role: item.role, content: item.content, position: item.position, depth: item.depth || 0 }); + continue; + } + // 有前邻组件 → 默认插到该组件之后(满足示例:位于 charDescription 之后、main 之前) + if (prevRef) { + result.push({ role: item.role, content: item.content, position: POSITIONS.AFTER_COMPONENT, _afterRef: prevRef }); + continue; + } + if (index === 0 && attach === 'prev') { + result.push({ role: item.role, content: item.content, position: POSITIONS.BEFORE_PROMPT }); + continue; + } + if (prevRef === 'chatHistory' || nextRef === 'chatHistory') { + result.push({ role: item.role, content: item.content, position: POSITIONS.IN_CHAT, depth: 0, _attach: attach === 'prev' ? 'before' : 'after' }); + continue; + } + result.push({ role: item.role, content: item.content, position: POSITIONS.IN_PROMPT }); + } + return result; + } + + /** + * 根据组件集合过滤消息(当 list 不含 ALL)。 + * @param {Array} messages + * @param {Set} wantedKeys + * @returns {Array} + */ + _filterMessagesByComponents(messages, wantedKeys) { + if (!wantedKeys || !wantedKeys.size) return []; + return messages.filter(m => wantedKeys.has(this._getComponentKeyFromIdentifier(m?.identifier))); + } + + /** 稳定重排:对目标子集按给定顺序排序,其他保持相对不变 */ + _stableReorderSubset(messages, orderedKeys) { + if (!Array.isArray(messages) || !orderedKeys || !orderedKeys.length) return messages; + const orderIndex = new Map(); + orderedKeys.forEach((k, i) => orderIndex.set(k, i)); + // 提取目标子集的元素与其原索引 + const targetIndices = []; + const targetMessages = []; + messages.forEach((m, idx) => { + const key = this._getComponentKeyFromIdentifier(m?.identifier); + if (orderIndex.has(key)) { + targetIndices.push(idx); + targetMessages.push({ m, ord: orderIndex.get(key) }); + } + }); + if (!targetIndices.length) return messages; + // 对目标子集按 ord 稳定排序 + targetMessages.sort((a, b) => a.ord - b.ord); + // 将排序后的目标消息放回原有“子集槽位”,非目标元素完全不动 + const out = messages.slice(); + for (let i = 0; i < targetIndices.length; i++) { + out[targetIndices[i]] = targetMessages[i].m; + } + return out; + } + + // ===== 缺失 identifier 的兜底标注 ===== + _normalizeText(s) { + return String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); + } + + _stripNamePrefix(s) { + return String(s || '').replace(/^\s*[^:]{1,32}:\s*/, ''); + } + + _normStrip(s) { return this._normalizeText(this._stripNamePrefix(s)); } + + _createIsFromChat() { + try { + const ctx = getContext(); + const chatArr = Array.isArray(ctx?.chat) ? ctx.chat : []; + const chatNorms = chatArr.map(m => this._normStrip(m?.mes)).filter(Boolean); + const chatSet = new Set(chatNorms); + return (content) => { + const n = this._normStrip(content); + if (!n) return false; + if (chatSet.has(n)) return true; + for (const c of chatNorms) { + const a = n.length, b = c.length; + const minL = Math.min(a, b), maxL = Math.max(a, b); + if (minL < 20) continue; + if (((a >= b && n.includes(c)) || (b >= a && c.includes(n))) && minL / maxL >= 0.8) return true; + } + return false; + }; + } catch { + return () => false; + } + } + + async _annotateIdentifiersIfMissing(messages, targetKeys) { + const arr = Array.isArray(messages) ? messages.map(m => ({ ...m })) : []; + if (!arr.length) return arr; + const hasIdentifier = arr.some(m => typeof m?.identifier === 'string' && m.identifier); + // 标注 chatHistory:依据 role + 来源判断 + const isFromChat = this._createIsFromChat(); + for (const m of arr) { + if (!m?.identifier && (m?.role === 'user' || m?.role === 'assistant') && isFromChat(m.content)) { + m.identifier = 'chatHistory-annotated'; + } + } + // 即使部分已有 identifier,也继续尝试为缺失者做 footprint 标注 + // 若仍缺失,按目标 keys 单独捕获来反向标注 + const keys = Array.from(new Set((Array.isArray(targetKeys) ? targetKeys : []).filter(Boolean))); + if (!keys.length) return arr; + const footprint = new Map(); // key -> Set of norm strings + for (const key of keys) { + try { + if (key === 'chatHistory') continue; // 已在上面标注 + // footprint 缓存命中 + const fpCache = this._getFootprintCache(); + if (fpCache.has(key)) { + footprint.set(key, fpCache.get(key)); + } else { + 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 {} + } + } catch {} + } + for (const m of arr) { + if (m?.identifier) continue; + const sig = `[${m?.role}] ${this._normStrip(m?.content)}`; + for (const [key, set] of footprint.entries()) { + if (set.has(sig)) { m.identifier = key; break; } + } + } + return arr; + } + + /** 覆写:通用组件 disable/replace(文本级),不影响采样参数 */ + _applyGeneralOverrides(messages, overridesByComponent) { + if (!overridesByComponent) return messages; + let out = messages.slice(); + for (const ref in overridesByComponent) { + if (!Object.prototype.hasOwnProperty.call(overridesByComponent, ref)) continue; + const cfg = overridesByComponent[ref]; + if (!cfg || typeof cfg !== 'object') continue; + const key = this._parseComponentRefToken(ref); + if (!key) continue; + if (key === 'chatHistory') continue; // 历史专属逻辑另行处理 + const disable = !!cfg.disable; + const replace = typeof cfg.replace === 'string' ? cfg.replace : null; + if (disable) { + out = out.filter(m => this._getComponentKeyFromIdentifier(m?.identifier) !== key); + continue; + } + if (replace != null) { + out = out.map(m => this._getComponentKeyFromIdentifier(m?.identifier) === key ? { ...m, content: replace } : m); + } + } + return out; + } + + /** 仅对 chatHistory 应用 selector/replaceAll/replace */ + _applyChatHistoryOverride(messages, historyCfg) { + if (!historyCfg) return messages; + const all = messages.slice(); + const indexes = []; + for (let i = 0; i < all.length; i++) { + const m = all[i]; + if (this._getComponentKeyFromIdentifier(m?.identifier) === 'chatHistory') indexes.push(i); + } + if (indexes.length === 0) return messages; + if (historyCfg.disable) { + // 直接移除全部历史 + return all.filter((m, idx) => !indexes.includes(idx)); + } + const history = indexes.map(i => all[i]); + + // selector 过滤 + let selected = history.slice(); + if (historyCfg.selector) { + // 在历史子集上应用 selector + selected = this.applyChatHistorySelector(history, historyCfg.selector); + } + + // 替换逻辑 + let replaced = selected.slice(); + if (historyCfg.replaceAll && Array.isArray(historyCfg.with)) { + replaced = (historyCfg.with || []).map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replaceAll-${idx}` })); + } + if (Array.isArray(historyCfg.replace)) { + // 在 replaced 上按顺序执行多段替换 + for (const step of historyCfg.replace) { + const withArr = Array.isArray(step?.with) ? step.with : []; + const newMsgs = withArr.map((w, idx) => ({ role: w.role, content: w.content, identifier: `chatHistory-replace-${Date.now()}-${idx}` })); + let indices = []; + if (step?.indices?.values && Array.isArray(step.indices.values) && step.indices.values.length) { + const n = replaced.length; + indices = step.indices.values.map(v0 => { + let v = Number(v0); + if (Number.isNaN(v)) return -1; + if (v < 0) v = n + v; + return (v >= 0 && v < n) ? v : -1; + }).filter(v => v >= 0); + } else if (step?.range && (step.range.start !== undefined || step.range.end !== undefined)) { + let { start = 0, end = replaced.length - 1 } = step.range; + const n = replaced.length; + start = Number(start); end = Number(end); + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = n - 1; + if (start < 0) start = n + start; + if (end < 0) end = n + end; + start = Math.max(0, start); end = Math.min(n - 1, end); + if (start <= end) indices = Array.from({ length: end - start + 1 }, (_, k) => start + k); + } else if (step?.last != null) { + const k = Math.max(0, Number(step.last) || 0); + const n = replaced.length; + indices = k > 0 ? Array.from({ length: Math.min(k, n) }, (_, j) => n - k + j) : []; + } + if (indices.length) { + // 按出现顺序处理:先删除这些索引,再按同位置插入(采用最小索引处插入) + const set = new Set(indices); + const kept = replaced.filter((_, idx) => !set.has(idx)); + const insertAt = Math.min(...indices); + replaced = kept.slice(0, insertAt).concat(newMsgs).concat(kept.slice(insertAt)); + } + } + } + + // 将 replaced 合并回全量:找到历史的第一个索引,替换整个历史窗口 + const first = Math.min(...indexes); + const last = Math.max(...indexes); + const before = all.slice(0, first); + const after = all.slice(last + 1); + return before.concat(replaced).concat(after); + } + + /** 将高级 injections 应用到 messages */ + _applyAdvancedInjections(messages, injections = []) { + if (!Array.isArray(injections) || injections.length === 0) return messages; + const out = messages.slice(); + // 计算 chatHistory 边界 + const historyIdx = []; + for (let i = 0; i < out.length; i++) if (this._getComponentKeyFromIdentifier(out[i]?.identifier) === 'chatHistory') historyIdx.push(i); + const hasHistory = historyIdx.length > 0; + const historyStart = hasHistory ? Math.min(...historyIdx) : -1; + const historyEnd = hasHistory ? Math.max(...historyIdx) : -1; + for (const inj of injections) { + const role = inj?.role; const content = inj?.content; + if (!role || typeof content !== 'string') continue; + const forcedId = inj && typeof inj.identifier === 'string' && inj.identifier.trim() ? String(inj.identifier).trim() : null; + const msg = { role, content, identifier: forcedId || `injection-${inj.position || POSITIONS.IN_PROMPT}-${Date.now()}-${Math.random().toString(36).slice(2)}` }; + if (inj.position === POSITIONS.BEFORE_PROMPT) { + out.splice(0, 0, msg); + continue; + } + if (inj.position === POSITIONS.AFTER_COMPONENT) { + const ref = inj._afterRef || null; + let inserted = false; + if (ref) { + for (let i = out.length - 1; i >= 0; i--) { + const id = out[i]?.identifier; + if (this._identifierMatchesKey(id, ref) || this._getComponentKeyFromIdentifier(id) === ref) { + out.splice(i + 1, 0, msg); + inserted = true; break; + } + } + } + if (!inserted) { + // 回退同 IN_PROMPT + if (hasHistory) { + const depth = Math.max(0, Number(inj.depth) || 0); + const insertPos = Math.max(0, historyStart - depth); + out.splice(insertPos, 0, msg); + } else { + out.splice(0, 0, msg); + } + } + continue; + } + if (inj.position === POSITIONS.IN_CHAT && hasHistory) { + // depth=0 → 历史末尾后;depth>0 → 进入历史内部; + const depth = Math.max(0, Number(inj.depth) || 0); + if (inj._attach === 'before') { + const insertPos = Math.max(historyStart - depth, 0); + out.splice(insertPos, 0, msg); + } else { + const insertPos = Math.min(out.length, historyEnd + 1 - depth); + out.splice(Math.max(historyStart, insertPos), 0, msg); + } + continue; + } + // IN_PROMPT 或无历史:在 chatHistory 之前插入,否则置顶后 + if (hasHistory) { + const depth = Math.max(0, Number(inj.depth) || 0); + const insertPos = Math.max(0, historyStart - depth); + out.splice(insertPos, 0, msg); + } else { + out.splice(0, 0, msg); + } + } + return out; + } + + _mergeMessages(baseMessages, extraMessages) { + const out = []; + const seen = new Set(); + const norm = (s) => String(s || '').replace(/[\r\t\u200B\u00A0]/g, '').replace(/\s+/g, ' ').replace(/^[("']+|[("']+$/g, '').trim(); + const push = (m) => { + if (!m || !m.content) return; + const key = `${m.role}:${norm(m.content)}`; + if (seen.has(key)) return; + seen.add(key); + out.push({ role: m.role, content: m.content }); + }; + baseMessages.forEach(push); + (extraMessages || []).forEach(push); + return out; + } + + _splitMessagesForHistoryOps(messages) { + // history: user/assistant; systemOther: 其余 + const history = []; + const systemOther = []; + for (const m of messages) { + if (!m || typeof m.content !== 'string') continue; + if (m.role === 'user' || m.role === 'assistant') history.push(m); + else systemOther.push(m); + } + return { history, systemOther }; + } + + _applyRolesFilter(list, rolesCfg) { + if (!rolesCfg || (!rolesCfg.include && !rolesCfg.exclude)) return list; + const inc = Array.isArray(rolesCfg.include) && rolesCfg.include.length ? new Set(rolesCfg.include) : null; + const exc = Array.isArray(rolesCfg.exclude) && rolesCfg.exclude.length ? new Set(rolesCfg.exclude) : null; + return list.filter(m => { + const r = m.role; + if (inc && !inc.has(r)) return false; + if (exc && exc.has(r)) return false; + return true; + }); + } + + _applyContentFilter(list, filterCfg) { + if (!filterCfg) return list; + const { contains, regex, fromUserNames, beforeTs, afterTs } = filterCfg; + let out = list.slice(); + if (contains) { + const needles = Array.isArray(contains) ? contains : [contains]; + out = out.filter(m => needles.some(k => String(m.content).includes(String(k)))); + } + if (regex) { + try { + const re = new RegExp(regex); + out = out.filter(m => re.test(String(m.content))); + } catch {} + } + if (fromUserNames && fromUserNames.length) { + // 仅当 messages 中附带 name 时生效;否则忽略 + out = out.filter(m => !m.name || fromUserNames.includes(m.name)); + } + // 时间戳过滤需要原始数据支持,这里忽略(占位) + return out; + } + + _applyAnchorWindow(list, anchorCfg) { + if (!anchorCfg || !list.length) return list; + const { anchor = 'lastUser', before = 0, after = 0 } = anchorCfg; + // 找到锚点索引 + let idx = -1; + if (anchor === 'lastUser') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'user') { idx = i; break; } + } else if (anchor === 'lastAssistant') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'assistant') { idx = i; break; } + } else if (anchor === 'lastSystem') { + for (let i = list.length - 1; i >= 0; i--) if (list[i].role === 'system') { idx = i; break; } + } + if (idx === -1) return list; + const start = Math.max(0, idx - Number(before || 0)); + const end = Math.min(list.length - 1, idx + Number(after || 0)); + return list.slice(start, end + 1); + } + + _applyIndicesRange(list, selector) { + const idxBase = selector?.indexBase === 'all' ? 'all' : 'history'; + let result = list.slice(); + // indices 优先 + if (Array.isArray(selector?.indices?.values) && selector.indices.values.length) { + const vals = selector.indices.values; + const picked = []; + const n = list.length; + for (const v0 of vals) { + let v = Number(v0); + if (Number.isNaN(v)) continue; + if (v < 0) v = n + v; // 负索引 + if (v >= 0 && v < n) picked.push(list[v]); + } + result = picked; + return result; + } + if (selector?.range && (selector.range.start !== undefined || selector.range.end !== undefined)) { + let { start = 0, end = list.length - 1 } = selector.range; + const n = list.length; + start = Number(start); end = Number(end); + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = n - 1; + if (start < 0) start = n + start; + if (end < 0) end = n + end; + start = Math.max(0, start); end = Math.min(n - 1, end); + if (start > end) return []; + return list.slice(start, end + 1); + } + if (selector?.last !== undefined && selector.last !== null) { + const k = Math.max(0, Number(selector.last) || 0); + if (k === 0) return []; + const n = list.length; + return list.slice(Math.max(0, n - k)); + } + return result; + } + + _applyTakeEvery(list, step) { + const s = Math.max(1, Number(step) || 1); + if (s === 1) return list; + const out = []; + for (let i = 0; i < list.length; i += s) out.push(list[i]); + return out; + } + + _applyLimit(list, limitCfg) { + if (!limitCfg) return list; + // 仅实现 count,tokenBudget 预留 + const count = Number(limitCfg.count || 0); + if (count > 0 && list.length > count) { + const how = limitCfg.truncateStrategy || 'last'; + if (how === 'first') return list.slice(0, count); + if (how === 'middle') { + const left = Math.floor(count / 2); + const right = count - left; + return list.slice(0, left).concat(list.slice(-right)); + } + if (how === 'even') { + const step = Math.ceil(list.length / count); + const out = []; + for (let i = 0; i < list.length && out.length < count; i += step) out.push(list[i]); + return out; + } + // default: 'last' → 取末尾 + return list.slice(-count); + } + return list; + } + + applyChatHistorySelector(messages, selector) { + if (!selector || !Array.isArray(messages) || !messages.length) return messages; + const { history, systemOther } = this._splitMessagesForHistoryOps(messages); + let list = history; + // roles/filter/anchor → indices/range/last → takeEvery → limit + list = this._applyRolesFilter(list, selector.roles); + list = this._applyContentFilter(list, selector.filter); + list = this._applyAnchorWindow(list, selector.anchorWindow); + list = this._applyIndicesRange(list, selector); + list = this._applyTakeEvery(list, selector.takeEvery); + list = this._applyLimit(list, selector.limit || (selector.last ? { count: Number(selector.last) } : null)); + // 合并非历史部分 + return systemOther.concat(list); + } + + // ===== 发送实现(构建后的统一发送) ===== + + async _sendMessages(messages, options, requestId, sourceWindow) { + const sessionId = this.normalizeSessionId(options?.session?.id || 'xb1'); + const session = this.ensureSession(sessionId); + const streamingEnabled = options?.streaming?.enabled !== false; // 默认开 + const apiCfg = this.resolveApiConfig(options?.api || {}); + const payload = this.buildChatPayload(messages, apiCfg, streamingEnabled); + + try { + const shouldExport = !!(options?.debug?.enabled || options?.debug?.exportPrompt); + const already = options?.debug?._exported === true; + if (shouldExport && !already) { + this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: (messages || []).map(m => ({ role: m.role, content: m.content })) }); + } + + if (streamingEnabled) { + this.postToTarget(sourceWindow, 'generateStreamStart', { id: requestId, sessionId }); + const streamFn = await ChatCompletionService.sendRequest(payload, false, session.abortController.signal); + let last = ''; + const generator = typeof streamFn === 'function' ? streamFn() : null; + for await (const { text } of (generator || [])) { + const chunk = text.slice(last.length); + last = text; + session.accumulated = text; + this.postToTarget(sourceWindow, 'generateStreamChunk', { id: requestId, chunk, accumulated: text, metadata: {} }); + } + const result = { + success: true, + result: session.accumulated, + sessionId, + metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, + }; + this.postToTarget(sourceWindow, 'generateStreamComplete', { id: requestId, result }); + return result; + } else { + const extracted = await ChatCompletionService.sendRequest(payload, true, session.abortController.signal); + const result = { + success: true, + result: String((extracted && extracted.content) || ''), + sessionId, + metadata: { duration: Date.now() - session.startedAt, model: apiCfg.model, finishReason: 'stop' }, + }; + this.postToTarget(sourceWindow, 'generateResult', { id: requestId, result }); + return result; + } + } catch (err) { + this.sendError(sourceWindow, requestId, streamingEnabled, err); + return null; + } + } + + // ===== 主流程 ===== + async handleRequestInternal(options, requestId, sourceWindow) { + // 1) 校验 + this.validateOptions(options); + + // 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 = []; + 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 {} + 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 {} + 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) 依据策略计算启用集合与顺序 + const annotateKeys = baseStrategy === 'SUBSET' ? orderedRefs : ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') ? orderedRefs : []); + 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 }); + + // 9) 发送 + return await this._sendMessages(working, { ...options, debug: { ...(options?.debug || {}), _exported: true } }, requestId, sourceWindow); + } + + _applyOrderingStrategy(messages, baseStrategy, orderedRefs, unorderedKeys) { + let out = messages.slice(); + if (baseStrategy === 'SUBSET') { + const want = new Set(orderedRefs); + out = this._filterMessagesByComponents(out, want); + } else if ((baseStrategy === 'ALL' || baseStrategy === 'ALL_PREON') && orderedRefs.length) { + const targets = orderedRefs.filter(k => !unorderedKeys.has(k)); + if (targets.length) out = this._stableReorderSubset(out, targets); + } + return out; + } + + _applyInlineOverrides(messages, byComp) { + let out = messages.slice(); + if (!byComp) return out; + out = this._applyGeneralOverrides(out, byComp); + const ensureInjections = []; + for (const ref in byComp) { + if (!Object.prototype.hasOwnProperty.call(byComp, ref)) continue; + const key = this._parseComponentRefToken(ref); + if (!key || key === 'chatHistory') continue; + const cfg = byComp[ref]; + if (!cfg || typeof cfg.replace !== 'string') continue; + const exists = out.some(m => this._identifierMatchesKey(m?.identifier, key) || this._getComponentKeyFromIdentifier(m?.identifier) === key); + if (exists) continue; + const map = this._mapCreateAnchorForKey(key); + ensureInjections.push({ position: map.position, role: map.role, content: cfg.replace, identifier: map.identifier }); + } + if (ensureInjections.length) { + out = this._applyAdvancedInjections(out, ensureInjections); + } + if (byComp['id:chatHistory'] || byComp['chatHistory']) { + const cfg = byComp['id:chatHistory'] || byComp['chatHistory']; + out = this._applyChatHistoryOverride(out, cfg); + } + return out; + } + + _applyAllInjections(messages, inlineMapped, advancedInjections) { + let out = messages.slice(); + if (inlineMapped && inlineMapped.length) { + out = this._applyAdvancedInjections(out, inlineMapped); + } + if (Array.isArray(advancedInjections) && advancedInjections.length) { + out = this._applyAdvancedInjections(out, advancedInjections); + } + return out; + } + + _appendUserInput(messages, userInput) { + const out = messages.slice(); + if (typeof userInput === 'string' && userInput.length >= 0) { + out.push({ role: 'user', content: String(userInput || ''), identifier: 'userInput' }); + } + return out; + } + + _exportDebugData({ sourceWindow, requestId, working, baseStrategy, orderedRefs, inlineMapped, listLevelOverrides, debug }) { + const exportPrompt = !!(debug?.enabled || debug?.exportPrompt); + if (exportPrompt) this.postToTarget(sourceWindow, 'generatePromptPreview', { id: requestId, messages: working.map(m => ({ role: m.role, content: m.content })) }); + if (debug?.exportBlueprint) { + try { + const bp = { + id: requestId, + components: { strategy: baseStrategy, order: orderedRefs }, + injections: (debug?.injections || []).concat(inlineMapped || []), + overrides: listLevelOverrides || null, + }; + this.postToTarget(sourceWindow, 'blueprint', bp); + } catch {} + } + } + + /** + * 入口:处理 generateRequest(统一入口) + */ async handleGenerateRequest(options, requestId, sourceWindow) { let streamingEnabled = false; try { @@ -1376,30 +1376,30 @@ class CallGenerateService { return null; } } - - /** 取消会话 */ - cancel(sessionId) { - const s = this.sessions.get(this.normalizeSessionId(sessionId)); - try { s?.abortController?.abort(); } catch {} - } - - /** 清理所有会话 */ - cleanup() { - this.sessions.forEach(s => { try { s.abortController?.abort(); } catch {} }); - this.sessions.clear(); - } -} - -const callGenerateService = new CallGenerateService(); - -export async function handleGenerateRequest(options, requestId, sourceWindow) { - return await callGenerateService.handleGenerateRequest(options, requestId, sourceWindow); -} - -// Host bridge for handling iframe generateRequest → respond via postMessage -let __xb_generate_listener_attached = false; -let __xb_generate_listener = null; - + + /** 取消会话 */ + cancel(sessionId) { + const s = this.sessions.get(this.normalizeSessionId(sessionId)); + try { s?.abortController?.abort(); } catch {} + } + + /** 清理所有会话 */ + cleanup() { + this.sessions.forEach(s => { try { s.abortController?.abort(); } catch {} }); + this.sessions.clear(); + } +} + +const callGenerateService = new CallGenerateService(); + +export async function handleGenerateRequest(options, requestId, sourceWindow) { + return await callGenerateService.handleGenerateRequest(options, requestId, sourceWindow); +} + +// Host bridge for handling iframe generateRequest → respond via postMessage +let __xb_generate_listener_attached = false; +let __xb_generate_listener = null; + export function initCallGenerateHostBridge() { if (typeof window === 'undefined') return; if (__xb_generate_listener_attached) return; @@ -1418,7 +1418,7 @@ export function initCallGenerateHostBridge() { 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; @@ -1428,119 +1428,119 @@ export function cleanupCallGenerateHostBridge() { __xb_generate_listener = null; try { callGenerateService.cleanup(); } catch (e) {} } - -if (typeof window !== 'undefined') { - Object.assign(window, { xiaobaixCallGenerateService: callGenerateService, initCallGenerateHostBridge, cleanupCallGenerateHostBridge }); - 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 (_) {} - }); - document.addEventListener('xiaobaixEnabledChanged', (e) => { - try { - const enabled = e && e.detail && e.detail.enabled === true; - if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); - } catch (_) {} - }); - window.addEventListener('beforeunload', () => { try { cleanupCallGenerateHostBridge(); } catch (_) {} }); - } catch (_) {} - - // ===== 全局 API 暴露:与 iframe 调用方式完全一致 ===== - // 创建命名空间 - window.LittleWhiteBox = window.LittleWhiteBox || {}; - - /** - * 全局 callGenerate 函数 - * 使用方式与 iframe 中完全一致:await window.callGenerate(options) - * - * @param {Object} options - 生成选项 - * @returns {Promise} 生成结果 - * - * @example - * // iframe 中的调用方式: - * const res = await window.callGenerate({ - * components: { list: ['ALL_PREON'] }, - * userInput: '你好', - * streaming: { enabled: true }, - * api: { inherit: true } - * }); - * - * // 全局调用方式(完全一致): - * const res = await window.LittleWhiteBox.callGenerate({ - * components: { list: ['ALL_PREON'] }, - * userInput: '你好', - * streaming: { enabled: true }, - * api: { inherit: true } - * }); - */ - 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 { - onChunkCallback(data.chunk, data.accumulated); - } catch (err) { - console.error('[callGenerate] onChunk callback error:', err); - } - } else if (data.type === 'generateStreamComplete') { - window.removeEventListener('message', listener); - resolve(data.result); - } else if (data.type === 'generateResult') { - window.removeEventListener('message', listener); - resolve(data.result); - } else if (data.type === 'generateStreamError' || data.type === 'generateError') { - window.removeEventListener('message', listener); - reject(data.error); - } - }; - - window.addEventListener('message', listener); - - // 发送请求 - handleGenerateRequest(options, requestId, window).catch(err => { - window.removeEventListener('message', listener); - reject(err); - }); - }); - }; - - /** - * 取消指定会话 - * @param {string} sessionId - 会话 ID(如 'xb1', 'xb2' 等) - */ - window.LittleWhiteBox.callGenerate.cancel = function(sessionId) { - callGenerateService.cancel(sessionId); - }; - - /** - * 清理所有会话 - */ - window.LittleWhiteBox.callGenerate.cleanup = function() { - callGenerateService.cleanup(); - }; - - // 保持向后兼容:保留原有的内部接口 - window.LittleWhiteBox._internal = { - service: callGenerateService, - handleGenerateRequest, - init: initCallGenerateHostBridge, - cleanup: cleanupCallGenerateHostBridge - }; + +if (typeof window !== 'undefined') { + Object.assign(window, { xiaobaixCallGenerateService: callGenerateService, initCallGenerateHostBridge, cleanupCallGenerateHostBridge }); + 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 (_) {} + }); + document.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initCallGenerateHostBridge(); else cleanupCallGenerateHostBridge(); + } catch (_) {} + }); + window.addEventListener('beforeunload', () => { try { cleanupCallGenerateHostBridge(); } catch (_) {} }); + } catch (_) {} + + // ===== 全局 API 暴露:与 iframe 调用方式完全一致 ===== + // 创建命名空间 + window.LittleWhiteBox = window.LittleWhiteBox || {}; + + /** + * 全局 callGenerate 函数 + * 使用方式与 iframe 中完全一致:await window.callGenerate(options) + * + * @param {Object} options - 生成选项 + * @returns {Promise} 生成结果 + * + * @example + * // iframe 中的调用方式: + * const res = await window.callGenerate({ + * components: { list: ['ALL_PREON'] }, + * userInput: '你好', + * streaming: { enabled: true }, + * api: { inherit: true } + * }); + * + * // 全局调用方式(完全一致): + * const res = await window.LittleWhiteBox.callGenerate({ + * components: { list: ['ALL_PREON'] }, + * userInput: '你好', + * streaming: { enabled: true }, + * api: { inherit: true } + * }); + */ + 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 { + onChunkCallback(data.chunk, data.accumulated); + } catch (err) { + console.error('[callGenerate] onChunk callback error:', err); + } + } else if (data.type === 'generateStreamComplete') { + window.removeEventListener('message', listener); + resolve(data.result); + } else if (data.type === 'generateResult') { + window.removeEventListener('message', listener); + resolve(data.result); + } else if (data.type === 'generateStreamError' || data.type === 'generateError') { + window.removeEventListener('message', listener); + reject(data.error); + } + }; + + window.addEventListener('message', listener); + + // 发送请求 + handleGenerateRequest(options, requestId, window).catch(err => { + window.removeEventListener('message', listener); + reject(err); + }); + }); + }; + + /** + * 取消指定会话 + * @param {string} sessionId - 会话 ID(如 'xb1', 'xb2' 等) + */ + window.LittleWhiteBox.callGenerate.cancel = function(sessionId) { + callGenerateService.cancel(sessionId); + }; + + /** + * 清理所有会话 + */ + window.LittleWhiteBox.callGenerate.cleanup = function() { + callGenerateService.cleanup(); + }; + + // 保持向后兼容:保留原有的内部接口 + window.LittleWhiteBox._internal = { + service: callGenerateService, + handleGenerateRequest, + init: initCallGenerateHostBridge, + cleanup: cleanupCallGenerateHostBridge + }; } diff --git a/bridges/worldbook-bridge.js b/bridges/worldbook-bridge.js index f3ff889..866b732 100644 --- a/bridges/worldbook-bridge.js +++ b/bridges/worldbook-bridge.js @@ -21,812 +21,812 @@ import { onWorldInfoChange, } from "../../../../world-info.js"; import { getCharaFilename, findChar } from "../../../../utils.js"; - -const SOURCE_TAG = "xiaobaix-host"; - -function isString(value) { - return typeof value === 'string'; -} - -function parseStringArray(input) { - if (input === undefined || input === null) return []; - const str = String(input).trim(); - try { - if (str.startsWith('[')) { - const arr = JSON.parse(str); - return Array.isArray(arr) ? arr.map(x => String(x).trim()).filter(Boolean) : []; - } - } catch {} - return str.split(',').map(x => x.trim()).filter(Boolean); -} - -function isTrueBoolean(value) { - const v = String(value).trim().toLowerCase(); - return v === 'true' || v === '1' || v === 'on' || v === 'yes'; -} - -function isFalseBoolean(value) { - const v = String(value).trim().toLowerCase(); - return v === 'false' || v === '0' || v === 'off' || v === 'no'; -} - -function ensureTimedWorldInfo(ctx) { - if (!ctx.chatMetadata.timedWorldInfo) ctx.chatMetadata.timedWorldInfo = {}; - return ctx.chatMetadata.timedWorldInfo; -} - -class WorldbookBridgeService { - constructor() { - this._listener = null; - this._forwardEvents = false; - this._attached = false; - this._allowedOrigins = ['*']; // Default: allow all origins - } - - setAllowedOrigins(origins) { - this._allowedOrigins = Array.isArray(origins) ? origins : [origins]; - } - - isOriginAllowed(origin) { - if (this._allowedOrigins.includes('*')) return true; - return this._allowedOrigins.some(allowed => { - if (allowed === origin) return true; - // Support wildcard subdomains like *.example.com - if (allowed.startsWith('*.')) { - const domain = allowed.slice(2); - return origin.endsWith('.' + domain) || origin === domain; - } - return false; - }); - } - - normalizeError(err, fallbackCode = 'API_ERROR', details = null) { - try { - if (!err) return { code: fallbackCode, message: 'Unknown error', details }; - if (typeof err === 'string') return { code: fallbackCode, message: err, details }; - const msg = err?.message || String(err); - return { code: fallbackCode, message: msg, details }; - } catch { - return { code: fallbackCode, message: 'Error serialization failed', details }; - } - } - - sendResult(target, requestId, result) { - try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookResult', id: requestId, result }, '*'); } catch {} - } - - sendError(target, requestId, err, fallbackCode = 'API_ERROR', details = null) { - const e = this.normalizeError(err, fallbackCode, details); - try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookError', id: requestId, error: e }, '*'); } catch {} - } - - postEvent(event, payload) { - try { window?.postMessage({ source: SOURCE_TAG, type: 'worldbookEvent', event, payload }, '*'); } catch {} - } - - async ensureWorldExists(name, autoCreate) { - if (!isString(name) || !name.trim()) throw new Error('MISSING_PARAMS'); - if (world_names?.includes(name)) return name; - if (!autoCreate) throw new Error(`Worldbook not found: ${name}`); - await createNewWorldInfo(name, { interactive: false }); - await updateWorldInfoList(); - return name; - } - - // ===== Basic actions ===== - async getChatBook(params) { - const ctx = getContext(); - const name = ctx.chatMetadata?.[METADATA_KEY]; - if (name && world_names?.includes(name)) return name; - const desired = isString(params?.name) ? String(params.name) : null; - const newName = desired && !world_names.includes(desired) - ? desired - : `Chat Book ${ctx.getCurrentChatId?.() || ''}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64); - await createNewWorldInfo(newName, { interactive: false }); - ctx.chatMetadata[METADATA_KEY] = newName; - await ctx.saveMetadata(); - return newName; - } - - async getGlobalBooks() { - if (!selected_world_info?.length) return JSON.stringify([]); - return JSON.stringify(selected_world_info.slice()); - } - - async listWorldbooks() { - return Array.isArray(world_names) ? world_names.slice() : []; - } - - async getPersonaBook() { - const ctx = getContext(); - return ctx.powerUserSettings?.persona_description_lorebook || ''; - } - - async getCharBook(params) { - const ctx = getContext(); - const type = String(params?.type ?? 'primary').toLowerCase(); - let characterName = params?.name ?? null; - if (!characterName) { - const active = ctx.characters?.[ctx.characterId]; - characterName = active?.avatar || active?.name || ''; - } - const character = findChar({ name: characterName, allowAvatar: true, preferCurrentChar: false, quiet: true }); - if (!character) return type === 'primary' ? '' : JSON.stringify([]); - - const books = []; - if (type === 'all' || type === 'primary') { - books.push(character.data?.extensions?.world); - } - if (type === 'all' || type === 'additional') { - const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); - const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); - if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) books.push(...extraCharLore.extraBooks); - } - if (type === 'primary') return books[0] ?? ''; - return JSON.stringify(books.filter(Boolean)); - } - - async world(params) { - const state = params?.state ?? undefined; // 'on'|'off'|'toggle'|undefined - const silent = !!params?.silent; - const name = isString(params?.name) ? params.name : ''; - // Use internal callback to ensure parity with STscript behavior - await onWorldInfoChange({ state, silent }, name); - return ''; - } - - // ===== Entries ===== - async findEntry(params) { - const file = params?.file; - const field = params?.field || 'key'; - const text = String(params?.text ?? '').trim(); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) return ''; - const entries = Object.values(data.entries); - if (!entries.length) return ''; - - let needle = text; - if (typeof newWorldInfoEntryTemplate[field] === 'boolean') { - if (isTrueBoolean(text)) needle = 'true'; - else if (isFalseBoolean(text)) needle = 'false'; - } - - let FuseRef = null; - try { FuseRef = window?.Fuse || Fuse; } catch {} - if (FuseRef) { - const fuse = new FuseRef(entries, { keys: [{ name: field, weight: 1 }], includeScore: true, threshold: 0.3 }); - const results = fuse.search(needle); - const uid = results?.[0]?.item?.uid; - return uid === undefined ? '' : String(uid); - } else { - // Fallback: simple includes on stringified field - const f = entries.find(e => String((Array.isArray(e[field]) ? e[field].join(' ') : e[field]) ?? '').toLowerCase().includes(needle.toLowerCase())); - return f?.uid !== undefined ? String(f.uid) : ''; - } - } - - async getEntryField(params) { - const file = params?.file; - const field = params?.field || 'content'; - const uid = String(params?.uid ?? '').trim(); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) return ''; - const entry = data.entries[uid]; - if (!entry) return ''; - if (newWorldInfoEntryTemplate[field] === undefined) return ''; - - const ctx = getContext(); - const tags = ctx.tags || []; - - let fieldValue; - switch (field) { - case 'characterFilterNames': - fieldValue = entry.characterFilter ? entry.characterFilter.names : undefined; - if (Array.isArray(fieldValue)) { - // Map avatar keys back to friendly names if possible (best-effort) - return JSON.stringify(fieldValue.slice()); - } - break; - case 'characterFilterTags': - fieldValue = entry.characterFilter ? entry.characterFilter.tags : undefined; - if (!Array.isArray(fieldValue)) return ''; - return JSON.stringify(tags.filter(tag => fieldValue.includes(tag.id)).map(tag => tag.name)); - case 'characterFilterExclude': - fieldValue = entry.characterFilter ? entry.characterFilter.isExclude : undefined; - break; - default: - fieldValue = entry[field]; - } - - if (fieldValue === undefined) return ''; - if (Array.isArray(fieldValue)) return JSON.stringify(fieldValue.map(x => String(x))); - return String(fieldValue); - } - - async setEntryField(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const field = params?.field || 'content'; - let value = params?.value; - if (value === undefined) throw new Error('MISSING_PARAMS'); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = data.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - if (newWorldInfoEntryTemplate[field] === undefined) throw new Error('VALIDATION_FAILED: field'); - - const ctx = getContext(); - const tags = ctx.tags || []; - - const ensureCharacterFilterObject = () => { - if (!entry.characterFilter) { - Object.assign(entry, { characterFilter: { isExclude: false, names: [], tags: [] } }); - } - }; - - // Unescape escaped special chars (compat with STscript input style) - value = String(value).replace(/\\([{}|])/g, '$1'); - - switch (field) { - case 'characterFilterNames': { - ensureCharacterFilterObject(); - const names = parseStringArray(value); - const avatars = names - .map((name) => findChar({ name, allowAvatar: true, preferCurrentChar: false, quiet: true })?.avatar) - .filter(Boolean); - // Convert to canonical filenames - entry.characterFilter.names = avatars - .map((avatarKey) => getCharaFilename(null, { manualAvatarKey: avatarKey })) - .filter(Boolean); - setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); - break; - } - case 'characterFilterTags': { - ensureCharacterFilterObject(); - const tagNames = parseStringArray(value); - entry.characterFilter.tags = tags.filter((t) => tagNames.includes(t.name)).map((t) => t.id); - setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); - break; - } - case 'characterFilterExclude': { - ensureCharacterFilterObject(); - entry.characterFilter.isExclude = isTrueBoolean(value); - setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); - break; - } - default: { - if (Array.isArray(entry[field])) { - entry[field] = parseStringArray(value); - } else if (typeof entry[field] === 'boolean') { - entry[field] = isTrueBoolean(value); - } else if (typeof entry[field] === 'number') { - entry[field] = Number(value); - } else { - entry[field] = String(value); - } - if (originalWIDataKeyMap[field]) { - setWIOriginalDataValue(data, uid, originalWIDataKeyMap[field], entry[field]); - } - break; - } - } - - await saveWorldInfo(file, data, true); - reloadEditor(file); - this.postEvent('ENTRY_UPDATED', { file, uid, fields: [field] }); - return ''; - } - - async createEntry(params) { - const file = params?.file; - const key = params?.key; - const content = params?.content; - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = createWorldInfoEntry(file, data); - if (key) { entry.key.push(String(key)); entry.addMemo = true; entry.comment = String(key); } - if (content) entry.content = String(content); - await saveWorldInfo(file, data, true); - reloadEditor(file); - this.postEvent('ENTRY_CREATED', { file, uid: entry.uid }); - return String(entry.uid); - } - - async listEntries(params) { - const file = params?.file; - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) return []; - return Object.values(data.entries).map(e => ({ - uid: e.uid, - comment: e.comment || '', - key: Array.isArray(e.key) ? e.key.slice() : [], - keysecondary: Array.isArray(e.keysecondary) ? e.keysecondary.slice() : [], - position: e.position, - depth: e.depth, - order: e.order, - probability: e.probability, - useProbability: !!e.useProbability, - disable: !!e.disable, - })); - } - - async deleteEntry(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const ok = await deleteWorldInfoEntry(data, uid, { silent: true }); - if (ok) { - await saveWorldInfo(file, data, true); - reloadEditor(file); - this.postEvent('ENTRY_DELETED', { file, uid }); - } - return ok ? 'ok' : ''; - } - - // ===== Enhanced Entry Operations ===== - async getEntryAll(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = data.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - - const ctx = getContext(); - const tags = ctx.tags || []; - const result = {}; - - // Get all template fields - for (const field of Object.keys(newWorldInfoEntryTemplate)) { - try { - result[field] = await this.getEntryField({ file, uid, field }); - } catch { - result[field] = ''; - } - } - - return result; - } - - async batchSetEntryFields(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const fields = params?.fields || {}; - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - if (typeof fields !== 'object' || !fields) throw new Error('VALIDATION_FAILED: fields must be object'); - - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = data.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - - // Apply all field changes - for (const [field, value] of Object.entries(fields)) { - try { - await this.setEntryField({ file, uid, field, value }); - } catch (err) { - // Continue with other fields, but collect errors - console.warn(`Failed to set field ${field}:`, err); - } - } - - this.postEvent('ENTRY_UPDATED', { file, uid, fields: Object.keys(fields) }); - return 'ok'; - } - - async cloneEntry(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const newKey = params?.newKey; - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const sourceEntry = data.entries[uid]; - if (!sourceEntry) throw new Error('NOT_FOUND'); - - // Create new entry with same data - const newEntry = createWorldInfoEntry(file, data); - - // Copy all fields from source (except uid which is auto-generated) - for (const [key, value] of Object.entries(sourceEntry)) { - if (key !== 'uid') { - if (Array.isArray(value)) { - newEntry[key] = value.slice(); - } else if (typeof value === 'object' && value !== null) { - newEntry[key] = JSON.parse(JSON.stringify(value)); - } else { - newEntry[key] = value; - } - } - } - - // Update key if provided - if (newKey) { - newEntry.key = [String(newKey)]; - newEntry.comment = `Copy of: ${String(newKey)}`; - } else if (sourceEntry.comment) { - newEntry.comment = `Copy of: ${sourceEntry.comment}`; - } - - await saveWorldInfo(file, data, true); - reloadEditor(file); - this.postEvent('ENTRY_CREATED', { file, uid: newEntry.uid, clonedFrom: uid }); - return String(newEntry.uid); - } - - async moveEntry(params) { - const sourceFile = params?.sourceFile; - const targetFile = params?.targetFile; - const uid = String(params?.uid ?? '').trim(); - if (!sourceFile || !world_names.includes(sourceFile)) throw new Error('VALIDATION_FAILED: sourceFile'); - if (!targetFile || !world_names.includes(targetFile)) throw new Error('VALIDATION_FAILED: targetFile'); - - const sourceData = await loadWorldInfo(sourceFile); - const targetData = await loadWorldInfo(targetFile); - if (!sourceData?.entries || !targetData?.entries) throw new Error('NOT_FOUND'); - - const entry = sourceData.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - - // Create new entry in target with same data - const newEntry = createWorldInfoEntry(targetFile, targetData); - for (const [key, value] of Object.entries(entry)) { - if (key !== 'uid') { - if (Array.isArray(value)) { - newEntry[key] = value.slice(); - } else if (typeof value === 'object' && value !== null) { - newEntry[key] = JSON.parse(JSON.stringify(value)); - } else { - newEntry[key] = value; - } - } - } - - // Remove from source - delete sourceData.entries[uid]; - - // Save both files - await saveWorldInfo(sourceFile, sourceData, true); - await saveWorldInfo(targetFile, targetData, true); - reloadEditor(sourceFile); - reloadEditor(targetFile); - - this.postEvent('ENTRY_MOVED', { - sourceFile, - targetFile, - oldUid: uid, - newUid: newEntry.uid - }); - return String(newEntry.uid); - } - - async reorderEntry(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const newOrder = Number(params?.newOrder ?? 0); - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = data.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - - entry.order = newOrder; - setWIOriginalDataValue(data, uid, 'order', newOrder); - - await saveWorldInfo(file, data, true); - reloadEditor(file); - this.postEvent('ENTRY_UPDATED', { file, uid, fields: ['order'] }); - return 'ok'; - } - - // ===== File-level Operations ===== - async renameWorldbook(params) { - const oldName = params?.oldName; - const newName = params?.newName; - if (!oldName || !world_names.includes(oldName)) throw new Error('VALIDATION_FAILED: oldName'); - if (!newName || world_names.includes(newName)) throw new Error('VALIDATION_FAILED: newName already exists'); - - // This is a complex operation that would require ST core support - // For now, we'll throw an error indicating it's not implemented - throw new Error('NOT_IMPLEMENTED: renameWorldbook requires ST core support'); - } - - async deleteWorldbook(params) { - const name = params?.name; - if (!name || !world_names.includes(name)) throw new Error('VALIDATION_FAILED: name'); - - // This is a complex operation that would require ST core support - // For now, we'll throw an error indicating it's not implemented - throw new Error('NOT_IMPLEMENTED: deleteWorldbook requires ST core support'); - } - - async exportWorldbook(params) { - const file = params?.file; - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - - const data = await loadWorldInfo(file); - if (!data) throw new Error('NOT_FOUND'); - - return JSON.stringify(data, null, 2); - } - - async importWorldbook(params) { - const name = params?.name; - const jsonData = params?.data; - const overwrite = !!params?.overwrite; - - if (!name) throw new Error('VALIDATION_FAILED: name'); - if (!jsonData) throw new Error('VALIDATION_FAILED: data'); - - if (world_names.includes(name) && !overwrite) { - throw new Error('VALIDATION_FAILED: worldbook exists and overwrite=false'); - } - - let data; - try { - data = JSON.parse(jsonData); - } catch { - throw new Error('VALIDATION_FAILED: invalid JSON data'); - } - - if (!world_names.includes(name)) { - await createNewWorldInfo(name, { interactive: false }); - await updateWorldInfoList(); - } - - await saveWorldInfo(name, data, true); - reloadEditor(name); - this.postEvent('WORLDBOOK_IMPORTED', { name }); - return 'ok'; - } - - // ===== Timed effects (minimal parity) ===== - async wiGetTimedEffect(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' - const format = String(params?.format ?? 'bool').trim().toLowerCase(); // 'bool'|'number' - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - if (!uid) throw new Error('MISSING_PARAMS'); - if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); - const ctx = getContext(); - const key = `${file}.${uid}`; - const t = ensureTimedWorldInfo(ctx); - const store = t[effect] || {}; - const meta = store[key]; - if (format === 'number') { - const remaining = meta ? Math.max(0, Number(meta.end || 0) - (ctx.chat?.length || 0)) : 0; - return String(remaining); - } - return String(!!meta); - } - - async wiSetTimedEffect(params) { - const file = params?.file; - const uid = String(params?.uid ?? '').trim(); - const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' - let value = params?.value; // 'toggle'|'true'|'false'|boolean - if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); - if (!uid) throw new Error('MISSING_PARAMS'); - if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); - const data = await loadWorldInfo(file); - if (!data || !data.entries) throw new Error('NOT_FOUND'); - const entry = data.entries[uid]; - if (!entry) throw new Error('NOT_FOUND'); - if (!entry[effect]) throw new Error('VALIDATION_FAILED: entry has no effect configured'); - - const ctx = getContext(); - const key = `${file}.${uid}`; - const t = ensureTimedWorldInfo(ctx); - if (!t[effect] || typeof t[effect] !== 'object') t[effect] = {}; - const store = t[effect]; - const current = !!store[key]; - - let newState; - const vs = String(value ?? '').trim().toLowerCase(); - if (vs === 'toggle' || vs === '') newState = !current; - else if (isTrueBoolean(vs)) newState = true; - else if (isFalseBoolean(vs)) newState = false; - else newState = current; - - if (newState) { - const duration = Number(entry[effect]) || 0; - store[key] = { end: (ctx.chat?.length || 0) + duration, world: file, uid }; - } else { - delete store[key]; - } - await ctx.saveMetadata(); - return ''; - } - - // ===== Bind / Unbind ===== - async bindWorldbookToChat(params) { - const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); - const ctx = getContext(); - ctx.chatMetadata[METADATA_KEY] = name; - await ctx.saveMetadata(); - return { name }; - } - - async unbindWorldbookFromChat() { - const ctx = getContext(); - delete ctx.chatMetadata[METADATA_KEY]; - await ctx.saveMetadata(); - return { name: '' }; - } - - async bindWorldbookToCharacter(params) { - const ctx = getContext(); - const target = String(params?.target ?? 'primary').toLowerCase(); - const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); - - const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; - const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); - if (!character) throw new Error('NOT_FOUND: character'); - - if (target === 'primary') { - if (typeof ctx.writeExtensionField === 'function') { - await ctx.writeExtensionField('world', name); - } else { - // Fallback: set on active character only - const active = ctx.characters?.[ctx.characterId]; - if (active) { - active.data = active.data || {}; - active.data.extensions = active.data.extensions || {}; - active.data.extensions.world = name; - } - } - return { primary: name }; - } - - // additional => world_info.charLore - const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); - let list = world_info.charLore || []; - const idx = list.findIndex(e => e.name === fileName); - if (idx === -1) { - list.push({ name: fileName, extraBooks: [name] }); - } else { - const eb = new Set(list[idx].extraBooks || []); - eb.add(name); - list[idx].extraBooks = Array.from(eb); - } - world_info.charLore = list; - getContext().saveSettingsDebounced?.(); - return { additional: (world_info.charLore.find(e => e.name === fileName)?.extraBooks) || [name] }; - } - - async unbindWorldbookFromCharacter(params) { - const ctx = getContext(); - const target = String(params?.target ?? 'primary').toLowerCase(); - const name = isString(params?.worldbookName) ? params.worldbookName : null; - const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; - const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); - if (!character) throw new Error('NOT_FOUND: character'); - - const result = {}; - if (target === 'primary' || target === 'all') { - if (typeof ctx.writeExtensionField === 'function') { - await ctx.writeExtensionField('world', ''); - } else { - const active = ctx.characters?.[ctx.characterId]; - if (active?.data?.extensions) active.data.extensions.world = ''; - } - result.primary = ''; - } - - if (target === 'additional' || target === 'all') { - const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); - let list = world_info.charLore || []; - const idx = list.findIndex(e => e.name === fileName); - if (idx !== -1) { - if (name) { - list[idx].extraBooks = (list[idx].extraBooks || []).filter(e => e !== name); - if (list[idx].extraBooks.length === 0) list.splice(idx, 1); - } else { - // remove all - list.splice(idx, 1); - } - world_info.charLore = list; - getContext().saveSettingsDebounced?.(); - result.additional = world_info.charLore.find(e => e.name === fileName)?.extraBooks || []; - } else { - result.additional = []; - } - } - return result; - } - - // ===== Dispatcher ===== - async handleRequest(action, params) { - switch (action) { - // Basic operations - case 'getChatBook': return await this.getChatBook(params); - case 'getGlobalBooks': return await this.getGlobalBooks(params); - case 'listWorldbooks': return await this.listWorldbooks(params); - case 'getPersonaBook': return await this.getPersonaBook(params); - case 'getCharBook': return await this.getCharBook(params); - case 'world': return await this.world(params); - - // Entry operations - case 'findEntry': return await this.findEntry(params); - case 'getEntryField': return await this.getEntryField(params); - case 'setEntryField': return await this.setEntryField(params); - case 'createEntry': return await this.createEntry(params); - case 'listEntries': return await this.listEntries(params); - case 'deleteEntry': return await this.deleteEntry(params); - - // Enhanced entry operations - case 'getEntryAll': return await this.getEntryAll(params); - case 'batchSetEntryFields': return await this.batchSetEntryFields(params); - case 'cloneEntry': return await this.cloneEntry(params); - case 'moveEntry': return await this.moveEntry(params); - case 'reorderEntry': return await this.reorderEntry(params); - - // File-level operations - case 'renameWorldbook': return await this.renameWorldbook(params); - case 'deleteWorldbook': return await this.deleteWorldbook(params); - case 'exportWorldbook': return await this.exportWorldbook(params); - case 'importWorldbook': return await this.importWorldbook(params); - - // Timed effects - case 'wiGetTimedEffect': return await this.wiGetTimedEffect(params); - case 'wiSetTimedEffect': return await this.wiSetTimedEffect(params); - - // Binding operations - case 'bindWorldbookToChat': return await this.bindWorldbookToChat(params); - case 'unbindWorldbookFromChat': return await this.unbindWorldbookFromChat(params); - case 'bindWorldbookToCharacter': return await this.bindWorldbookToCharacter(params); - case 'unbindWorldbookFromCharacter': return await this.unbindWorldbookFromCharacter(params); - - default: throw new Error('INVALID_ACTION'); - } - } - - attachEventsForwarding() { - if (this._forwardEvents) return; - this._onWIUpdated = (name, data) => this.postEvent('WORLDBOOK_UPDATED', { name }); - this._onWISettings = () => this.postEvent('WORLDBOOK_SETTINGS_UPDATED', {}); - this._onWIActivated = (entries) => this.postEvent('WORLDBOOK_ACTIVATED', { entries }); - eventSource.on(event_types.WORLDINFO_UPDATED, this._onWIUpdated); - eventSource.on(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); - eventSource.on(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); - this._forwardEvents = true; - } - - detachEventsForwarding() { - if (!this._forwardEvents) return; - try { eventSource.removeListener(event_types.WORLDINFO_UPDATED, this._onWIUpdated); } catch {} - try { eventSource.removeListener(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); } catch {} - try { eventSource.removeListener(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); } catch {} - this._forwardEvents = false; - } - - init({ forwardEvents = false, allowedOrigins = null } = {}) { - if (this._attached) return; - if (allowedOrigins) this.setAllowedOrigins(allowedOrigins); - - const self = this; - this._listener = async function (event) { - try { - // Security check: validate origin - if (!self.isOriginAllowed(event.origin)) { - console.warn('Worldbook bridge: Rejected request from unauthorized origin:', event.origin); - return; - } - - const data = event && event.data || {}; - if (!data || data.type !== 'worldbookRequest') return; + +const SOURCE_TAG = "xiaobaix-host"; + +function isString(value) { + return typeof value === 'string'; +} + +function parseStringArray(input) { + if (input === undefined || input === null) return []; + const str = String(input).trim(); + try { + if (str.startsWith('[')) { + const arr = JSON.parse(str); + return Array.isArray(arr) ? arr.map(x => String(x).trim()).filter(Boolean) : []; + } + } catch {} + return str.split(',').map(x => x.trim()).filter(Boolean); +} + +function isTrueBoolean(value) { + const v = String(value).trim().toLowerCase(); + return v === 'true' || v === '1' || v === 'on' || v === 'yes'; +} + +function isFalseBoolean(value) { + const v = String(value).trim().toLowerCase(); + return v === 'false' || v === '0' || v === 'off' || v === 'no'; +} + +function ensureTimedWorldInfo(ctx) { + if (!ctx.chatMetadata.timedWorldInfo) ctx.chatMetadata.timedWorldInfo = {}; + return ctx.chatMetadata.timedWorldInfo; +} + +class WorldbookBridgeService { + constructor() { + this._listener = null; + this._forwardEvents = false; + this._attached = false; + this._allowedOrigins = ['*']; // Default: allow all origins + } + + setAllowedOrigins(origins) { + this._allowedOrigins = Array.isArray(origins) ? origins : [origins]; + } + + isOriginAllowed(origin) { + if (this._allowedOrigins.includes('*')) return true; + return this._allowedOrigins.some(allowed => { + if (allowed === origin) return true; + // Support wildcard subdomains like *.example.com + if (allowed.startsWith('*.')) { + const domain = allowed.slice(2); + return origin.endsWith('.' + domain) || origin === domain; + } + return false; + }); + } + + normalizeError(err, fallbackCode = 'API_ERROR', details = null) { + try { + if (!err) return { code: fallbackCode, message: 'Unknown error', details }; + if (typeof err === 'string') return { code: fallbackCode, message: err, details }; + const msg = err?.message || String(err); + return { code: fallbackCode, message: msg, details }; + } catch { + return { code: fallbackCode, message: 'Error serialization failed', details }; + } + } + + sendResult(target, requestId, result) { + try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookResult', id: requestId, result }, '*'); } catch {} + } + + sendError(target, requestId, err, fallbackCode = 'API_ERROR', details = null) { + const e = this.normalizeError(err, fallbackCode, details); + try { target?.postMessage({ source: SOURCE_TAG, type: 'worldbookError', id: requestId, error: e }, '*'); } catch {} + } + + postEvent(event, payload) { + try { window?.postMessage({ source: SOURCE_TAG, type: 'worldbookEvent', event, payload }, '*'); } catch {} + } + + async ensureWorldExists(name, autoCreate) { + if (!isString(name) || !name.trim()) throw new Error('MISSING_PARAMS'); + if (world_names?.includes(name)) return name; + if (!autoCreate) throw new Error(`Worldbook not found: ${name}`); + await createNewWorldInfo(name, { interactive: false }); + await updateWorldInfoList(); + return name; + } + + // ===== Basic actions ===== + async getChatBook(params) { + const ctx = getContext(); + const name = ctx.chatMetadata?.[METADATA_KEY]; + if (name && world_names?.includes(name)) return name; + const desired = isString(params?.name) ? String(params.name) : null; + const newName = desired && !world_names.includes(desired) + ? desired + : `Chat Book ${ctx.getCurrentChatId?.() || ''}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64); + await createNewWorldInfo(newName, { interactive: false }); + ctx.chatMetadata[METADATA_KEY] = newName; + await ctx.saveMetadata(); + return newName; + } + + async getGlobalBooks() { + if (!selected_world_info?.length) return JSON.stringify([]); + return JSON.stringify(selected_world_info.slice()); + } + + async listWorldbooks() { + return Array.isArray(world_names) ? world_names.slice() : []; + } + + async getPersonaBook() { + const ctx = getContext(); + return ctx.powerUserSettings?.persona_description_lorebook || ''; + } + + async getCharBook(params) { + const ctx = getContext(); + const type = String(params?.type ?? 'primary').toLowerCase(); + let characterName = params?.name ?? null; + if (!characterName) { + const active = ctx.characters?.[ctx.characterId]; + characterName = active?.avatar || active?.name || ''; + } + const character = findChar({ name: characterName, allowAvatar: true, preferCurrentChar: false, quiet: true }); + if (!character) return type === 'primary' ? '' : JSON.stringify([]); + + const books = []; + if (type === 'all' || type === 'primary') { + books.push(character.data?.extensions?.world); + } + if (type === 'all' || type === 'additional') { + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); + if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) books.push(...extraCharLore.extraBooks); + } + if (type === 'primary') return books[0] ?? ''; + return JSON.stringify(books.filter(Boolean)); + } + + async world(params) { + const state = params?.state ?? undefined; // 'on'|'off'|'toggle'|undefined + const silent = !!params?.silent; + const name = isString(params?.name) ? params.name : ''; + // Use internal callback to ensure parity with STscript behavior + await onWorldInfoChange({ state, silent }, name); + return ''; + } + + // ===== Entries ===== + async findEntry(params) { + const file = params?.file; + const field = params?.field || 'key'; + const text = String(params?.text ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return ''; + const entries = Object.values(data.entries); + if (!entries.length) return ''; + + let needle = text; + if (typeof newWorldInfoEntryTemplate[field] === 'boolean') { + if (isTrueBoolean(text)) needle = 'true'; + else if (isFalseBoolean(text)) needle = 'false'; + } + + let FuseRef = null; + try { FuseRef = window?.Fuse || Fuse; } catch {} + if (FuseRef) { + const fuse = new FuseRef(entries, { keys: [{ name: field, weight: 1 }], includeScore: true, threshold: 0.3 }); + const results = fuse.search(needle); + const uid = results?.[0]?.item?.uid; + return uid === undefined ? '' : String(uid); + } else { + // Fallback: simple includes on stringified field + const f = entries.find(e => String((Array.isArray(e[field]) ? e[field].join(' ') : e[field]) ?? '').toLowerCase().includes(needle.toLowerCase())); + return f?.uid !== undefined ? String(f.uid) : ''; + } + } + + async getEntryField(params) { + const file = params?.file; + const field = params?.field || 'content'; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return ''; + const entry = data.entries[uid]; + if (!entry) return ''; + if (newWorldInfoEntryTemplate[field] === undefined) return ''; + + const ctx = getContext(); + const tags = ctx.tags || []; + + let fieldValue; + switch (field) { + case 'characterFilterNames': + fieldValue = entry.characterFilter ? entry.characterFilter.names : undefined; + if (Array.isArray(fieldValue)) { + // Map avatar keys back to friendly names if possible (best-effort) + return JSON.stringify(fieldValue.slice()); + } + break; + case 'characterFilterTags': + fieldValue = entry.characterFilter ? entry.characterFilter.tags : undefined; + if (!Array.isArray(fieldValue)) return ''; + return JSON.stringify(tags.filter(tag => fieldValue.includes(tag.id)).map(tag => tag.name)); + case 'characterFilterExclude': + fieldValue = entry.characterFilter ? entry.characterFilter.isExclude : undefined; + break; + default: + fieldValue = entry[field]; + } + + if (fieldValue === undefined) return ''; + if (Array.isArray(fieldValue)) return JSON.stringify(fieldValue.map(x => String(x))); + return String(fieldValue); + } + + async setEntryField(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const field = params?.field || 'content'; + let value = params?.value; + if (value === undefined) throw new Error('MISSING_PARAMS'); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + if (newWorldInfoEntryTemplate[field] === undefined) throw new Error('VALIDATION_FAILED: field'); + + const ctx = getContext(); + const tags = ctx.tags || []; + + const ensureCharacterFilterObject = () => { + if (!entry.characterFilter) { + Object.assign(entry, { characterFilter: { isExclude: false, names: [], tags: [] } }); + } + }; + + // Unescape escaped special chars (compat with STscript input style) + value = String(value).replace(/\\([{}|])/g, '$1'); + + switch (field) { + case 'characterFilterNames': { + ensureCharacterFilterObject(); + const names = parseStringArray(value); + const avatars = names + .map((name) => findChar({ name, allowAvatar: true, preferCurrentChar: false, quiet: true })?.avatar) + .filter(Boolean); + // Convert to canonical filenames + entry.characterFilter.names = avatars + .map((avatarKey) => getCharaFilename(null, { manualAvatarKey: avatarKey })) + .filter(Boolean); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + case 'characterFilterTags': { + ensureCharacterFilterObject(); + const tagNames = parseStringArray(value); + entry.characterFilter.tags = tags.filter((t) => tagNames.includes(t.name)).map((t) => t.id); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + case 'characterFilterExclude': { + ensureCharacterFilterObject(); + entry.characterFilter.isExclude = isTrueBoolean(value); + setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); + break; + } + default: { + if (Array.isArray(entry[field])) { + entry[field] = parseStringArray(value); + } else if (typeof entry[field] === 'boolean') { + entry[field] = isTrueBoolean(value); + } else if (typeof entry[field] === 'number') { + entry[field] = Number(value); + } else { + entry[field] = String(value); + } + if (originalWIDataKeyMap[field]) { + setWIOriginalDataValue(data, uid, originalWIDataKeyMap[field], entry[field]); + } + break; + } + } + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_UPDATED', { file, uid, fields: [field] }); + return ''; + } + + async createEntry(params) { + const file = params?.file; + const key = params?.key; + const content = params?.content; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = createWorldInfoEntry(file, data); + if (key) { entry.key.push(String(key)); entry.addMemo = true; entry.comment = String(key); } + if (content) entry.content = String(content); + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_CREATED', { file, uid: entry.uid }); + return String(entry.uid); + } + + async listEntries(params) { + const file = params?.file; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) return []; + return Object.values(data.entries).map(e => ({ + uid: e.uid, + comment: e.comment || '', + key: Array.isArray(e.key) ? e.key.slice() : [], + keysecondary: Array.isArray(e.keysecondary) ? e.keysecondary.slice() : [], + position: e.position, + depth: e.depth, + order: e.order, + probability: e.probability, + useProbability: !!e.useProbability, + disable: !!e.disable, + })); + } + + async deleteEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const ok = await deleteWorldInfoEntry(data, uid, { silent: true }); + if (ok) { + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_DELETED', { file, uid }); + } + return ok ? 'ok' : ''; + } + + // ===== Enhanced Entry Operations ===== + async getEntryAll(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + const ctx = getContext(); + const tags = ctx.tags || []; + const result = {}; + + // Get all template fields + for (const field of Object.keys(newWorldInfoEntryTemplate)) { + try { + result[field] = await this.getEntryField({ file, uid, field }); + } catch { + result[field] = ''; + } + } + + return result; + } + + async batchSetEntryFields(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const fields = params?.fields || {}; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (typeof fields !== 'object' || !fields) throw new Error('VALIDATION_FAILED: fields must be object'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + // Apply all field changes + for (const [field, value] of Object.entries(fields)) { + try { + await this.setEntryField({ file, uid, field, value }); + } catch (err) { + // Continue with other fields, but collect errors + console.warn(`Failed to set field ${field}:`, err); + } + } + + this.postEvent('ENTRY_UPDATED', { file, uid, fields: Object.keys(fields) }); + return 'ok'; + } + + async cloneEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const newKey = params?.newKey; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const sourceEntry = data.entries[uid]; + if (!sourceEntry) throw new Error('NOT_FOUND'); + + // Create new entry with same data + const newEntry = createWorldInfoEntry(file, data); + + // Copy all fields from source (except uid which is auto-generated) + for (const [key, value] of Object.entries(sourceEntry)) { + if (key !== 'uid') { + if (Array.isArray(value)) { + newEntry[key] = value.slice(); + } else if (typeof value === 'object' && value !== null) { + newEntry[key] = JSON.parse(JSON.stringify(value)); + } else { + newEntry[key] = value; + } + } + } + + // Update key if provided + if (newKey) { + newEntry.key = [String(newKey)]; + newEntry.comment = `Copy of: ${String(newKey)}`; + } else if (sourceEntry.comment) { + newEntry.comment = `Copy of: ${sourceEntry.comment}`; + } + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_CREATED', { file, uid: newEntry.uid, clonedFrom: uid }); + return String(newEntry.uid); + } + + async moveEntry(params) { + const sourceFile = params?.sourceFile; + const targetFile = params?.targetFile; + const uid = String(params?.uid ?? '').trim(); + if (!sourceFile || !world_names.includes(sourceFile)) throw new Error('VALIDATION_FAILED: sourceFile'); + if (!targetFile || !world_names.includes(targetFile)) throw new Error('VALIDATION_FAILED: targetFile'); + + const sourceData = await loadWorldInfo(sourceFile); + const targetData = await loadWorldInfo(targetFile); + if (!sourceData?.entries || !targetData?.entries) throw new Error('NOT_FOUND'); + + const entry = sourceData.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + // Create new entry in target with same data + const newEntry = createWorldInfoEntry(targetFile, targetData); + for (const [key, value] of Object.entries(entry)) { + if (key !== 'uid') { + if (Array.isArray(value)) { + newEntry[key] = value.slice(); + } else if (typeof value === 'object' && value !== null) { + newEntry[key] = JSON.parse(JSON.stringify(value)); + } else { + newEntry[key] = value; + } + } + } + + // Remove from source + delete sourceData.entries[uid]; + + // Save both files + await saveWorldInfo(sourceFile, sourceData, true); + await saveWorldInfo(targetFile, targetData, true); + reloadEditor(sourceFile); + reloadEditor(targetFile); + + this.postEvent('ENTRY_MOVED', { + sourceFile, + targetFile, + oldUid: uid, + newUid: newEntry.uid + }); + return String(newEntry.uid); + } + + async reorderEntry(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const newOrder = Number(params?.newOrder ?? 0); + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + + entry.order = newOrder; + setWIOriginalDataValue(data, uid, 'order', newOrder); + + await saveWorldInfo(file, data, true); + reloadEditor(file); + this.postEvent('ENTRY_UPDATED', { file, uid, fields: ['order'] }); + return 'ok'; + } + + // ===== File-level Operations ===== + async renameWorldbook(params) { + const oldName = params?.oldName; + const newName = params?.newName; + if (!oldName || !world_names.includes(oldName)) throw new Error('VALIDATION_FAILED: oldName'); + if (!newName || world_names.includes(newName)) throw new Error('VALIDATION_FAILED: newName already exists'); + + // This is a complex operation that would require ST core support + // For now, we'll throw an error indicating it's not implemented + throw new Error('NOT_IMPLEMENTED: renameWorldbook requires ST core support'); + } + + async deleteWorldbook(params) { + const name = params?.name; + if (!name || !world_names.includes(name)) throw new Error('VALIDATION_FAILED: name'); + + // This is a complex operation that would require ST core support + // For now, we'll throw an error indicating it's not implemented + throw new Error('NOT_IMPLEMENTED: deleteWorldbook requires ST core support'); + } + + async exportWorldbook(params) { + const file = params?.file; + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + + const data = await loadWorldInfo(file); + if (!data) throw new Error('NOT_FOUND'); + + return JSON.stringify(data, null, 2); + } + + async importWorldbook(params) { + const name = params?.name; + const jsonData = params?.data; + const overwrite = !!params?.overwrite; + + if (!name) throw new Error('VALIDATION_FAILED: name'); + if (!jsonData) throw new Error('VALIDATION_FAILED: data'); + + if (world_names.includes(name) && !overwrite) { + throw new Error('VALIDATION_FAILED: worldbook exists and overwrite=false'); + } + + let data; + try { + data = JSON.parse(jsonData); + } catch { + throw new Error('VALIDATION_FAILED: invalid JSON data'); + } + + if (!world_names.includes(name)) { + await createNewWorldInfo(name, { interactive: false }); + await updateWorldInfoList(); + } + + await saveWorldInfo(name, data, true); + reloadEditor(name); + this.postEvent('WORLDBOOK_IMPORTED', { name }); + return 'ok'; + } + + // ===== Timed effects (minimal parity) ===== + async wiGetTimedEffect(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' + const format = String(params?.format ?? 'bool').trim().toLowerCase(); // 'bool'|'number' + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (!uid) throw new Error('MISSING_PARAMS'); + if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); + const ctx = getContext(); + const key = `${file}.${uid}`; + const t = ensureTimedWorldInfo(ctx); + const store = t[effect] || {}; + const meta = store[key]; + if (format === 'number') { + const remaining = meta ? Math.max(0, Number(meta.end || 0) - (ctx.chat?.length || 0)) : 0; + return String(remaining); + } + return String(!!meta); + } + + async wiSetTimedEffect(params) { + const file = params?.file; + const uid = String(params?.uid ?? '').trim(); + const effect = String(params?.effect ?? '').trim().toLowerCase(); // 'sticky'|'cooldown' + let value = params?.value; // 'toggle'|'true'|'false'|boolean + if (!file || !world_names.includes(file)) throw new Error('VALIDATION_FAILED: file'); + if (!uid) throw new Error('MISSING_PARAMS'); + if (!['sticky', 'cooldown'].includes(effect)) throw new Error('VALIDATION_FAILED: effect'); + const data = await loadWorldInfo(file); + if (!data || !data.entries) throw new Error('NOT_FOUND'); + const entry = data.entries[uid]; + if (!entry) throw new Error('NOT_FOUND'); + if (!entry[effect]) throw new Error('VALIDATION_FAILED: entry has no effect configured'); + + const ctx = getContext(); + const key = `${file}.${uid}`; + const t = ensureTimedWorldInfo(ctx); + if (!t[effect] || typeof t[effect] !== 'object') t[effect] = {}; + const store = t[effect]; + const current = !!store[key]; + + let newState; + const vs = String(value ?? '').trim().toLowerCase(); + if (vs === 'toggle' || vs === '') newState = !current; + else if (isTrueBoolean(vs)) newState = true; + else if (isFalseBoolean(vs)) newState = false; + else newState = current; + + if (newState) { + const duration = Number(entry[effect]) || 0; + store[key] = { end: (ctx.chat?.length || 0) + duration, world: file, uid }; + } else { + delete store[key]; + } + await ctx.saveMetadata(); + return ''; + } + + // ===== Bind / Unbind ===== + async bindWorldbookToChat(params) { + const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); + const ctx = getContext(); + ctx.chatMetadata[METADATA_KEY] = name; + await ctx.saveMetadata(); + return { name }; + } + + async unbindWorldbookFromChat() { + const ctx = getContext(); + delete ctx.chatMetadata[METADATA_KEY]; + await ctx.saveMetadata(); + return { name: '' }; + } + + async bindWorldbookToCharacter(params) { + const ctx = getContext(); + const target = String(params?.target ?? 'primary').toLowerCase(); + const name = await this.ensureWorldExists(params?.worldbookName, !!params?.autoCreate); + + const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; + const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); + if (!character) throw new Error('NOT_FOUND: character'); + + if (target === 'primary') { + if (typeof ctx.writeExtensionField === 'function') { + await ctx.writeExtensionField('world', name); + } else { + // Fallback: set on active character only + const active = ctx.characters?.[ctx.characterId]; + if (active) { + active.data = active.data || {}; + active.data.extensions = active.data.extensions || {}; + active.data.extensions.world = name; + } + } + return { primary: name }; + } + + // additional => world_info.charLore + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + let list = world_info.charLore || []; + const idx = list.findIndex(e => e.name === fileName); + if (idx === -1) { + list.push({ name: fileName, extraBooks: [name] }); + } else { + const eb = new Set(list[idx].extraBooks || []); + eb.add(name); + list[idx].extraBooks = Array.from(eb); + } + world_info.charLore = list; + getContext().saveSettingsDebounced?.(); + return { additional: (world_info.charLore.find(e => e.name === fileName)?.extraBooks) || [name] }; + } + + async unbindWorldbookFromCharacter(params) { + const ctx = getContext(); + const target = String(params?.target ?? 'primary').toLowerCase(); + const name = isString(params?.worldbookName) ? params.worldbookName : null; + const charName = params?.character?.name || ctx.characters?.[ctx.characterId]?.avatar || ctx.characters?.[ctx.characterId]?.name; + const character = findChar({ name: charName, allowAvatar: true, preferCurrentChar: true, quiet: true }); + if (!character) throw new Error('NOT_FOUND: character'); + + const result = {}; + if (target === 'primary' || target === 'all') { + if (typeof ctx.writeExtensionField === 'function') { + await ctx.writeExtensionField('world', ''); + } else { + const active = ctx.characters?.[ctx.characterId]; + if (active?.data?.extensions) active.data.extensions.world = ''; + } + result.primary = ''; + } + + if (target === 'additional' || target === 'all') { + const fileName = getCharaFilename(null, { manualAvatarKey: character.avatar }); + let list = world_info.charLore || []; + const idx = list.findIndex(e => e.name === fileName); + if (idx !== -1) { + if (name) { + list[idx].extraBooks = (list[idx].extraBooks || []).filter(e => e !== name); + if (list[idx].extraBooks.length === 0) list.splice(idx, 1); + } else { + // remove all + list.splice(idx, 1); + } + world_info.charLore = list; + getContext().saveSettingsDebounced?.(); + result.additional = world_info.charLore.find(e => e.name === fileName)?.extraBooks || []; + } else { + result.additional = []; + } + } + return result; + } + + // ===== Dispatcher ===== + async handleRequest(action, params) { + switch (action) { + // Basic operations + case 'getChatBook': return await this.getChatBook(params); + case 'getGlobalBooks': return await this.getGlobalBooks(params); + case 'listWorldbooks': return await this.listWorldbooks(params); + case 'getPersonaBook': return await this.getPersonaBook(params); + case 'getCharBook': return await this.getCharBook(params); + case 'world': return await this.world(params); + + // Entry operations + case 'findEntry': return await this.findEntry(params); + case 'getEntryField': return await this.getEntryField(params); + case 'setEntryField': return await this.setEntryField(params); + case 'createEntry': return await this.createEntry(params); + case 'listEntries': return await this.listEntries(params); + case 'deleteEntry': return await this.deleteEntry(params); + + // Enhanced entry operations + case 'getEntryAll': return await this.getEntryAll(params); + case 'batchSetEntryFields': return await this.batchSetEntryFields(params); + case 'cloneEntry': return await this.cloneEntry(params); + case 'moveEntry': return await this.moveEntry(params); + case 'reorderEntry': return await this.reorderEntry(params); + + // File-level operations + case 'renameWorldbook': return await this.renameWorldbook(params); + case 'deleteWorldbook': return await this.deleteWorldbook(params); + case 'exportWorldbook': return await this.exportWorldbook(params); + case 'importWorldbook': return await this.importWorldbook(params); + + // Timed effects + case 'wiGetTimedEffect': return await this.wiGetTimedEffect(params); + case 'wiSetTimedEffect': return await this.wiSetTimedEffect(params); + + // Binding operations + case 'bindWorldbookToChat': return await this.bindWorldbookToChat(params); + case 'unbindWorldbookFromChat': return await this.unbindWorldbookFromChat(params); + case 'bindWorldbookToCharacter': return await this.bindWorldbookToCharacter(params); + case 'unbindWorldbookFromCharacter': return await this.unbindWorldbookFromCharacter(params); + + default: throw new Error('INVALID_ACTION'); + } + } + + attachEventsForwarding() { + if (this._forwardEvents) return; + this._onWIUpdated = (name, data) => this.postEvent('WORLDBOOK_UPDATED', { name }); + this._onWISettings = () => this.postEvent('WORLDBOOK_SETTINGS_UPDATED', {}); + this._onWIActivated = (entries) => this.postEvent('WORLDBOOK_ACTIVATED', { entries }); + eventSource.on(event_types.WORLDINFO_UPDATED, this._onWIUpdated); + eventSource.on(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); + eventSource.on(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); + this._forwardEvents = true; + } + + detachEventsForwarding() { + if (!this._forwardEvents) return; + try { eventSource.removeListener(event_types.WORLDINFO_UPDATED, this._onWIUpdated); } catch {} + try { eventSource.removeListener(event_types.WORLDINFO_SETTINGS_UPDATED, this._onWISettings); } catch {} + try { eventSource.removeListener(event_types.WORLD_INFO_ACTIVATED, this._onWIActivated); } catch {} + this._forwardEvents = false; + } + + init({ forwardEvents = false, allowedOrigins = null } = {}) { + if (this._attached) return; + if (allowedOrigins) this.setAllowedOrigins(allowedOrigins); + + const self = this; + this._listener = async function (event) { + try { + // Security check: validate origin + if (!self.isOriginAllowed(event.origin)) { + console.warn('Worldbook bridge: Rejected request from unauthorized origin:', event.origin); + return; + } + + const data = event && event.data || {}; + if (!data || data.type !== 'worldbookRequest') return; const id = data.id; const action = data.action; const params = data.params || {}; @@ -848,7 +848,7 @@ class WorldbookBridgeService { this._attached = true; if (forwardEvents) this.attachEventsForwarding(); } - + cleanup() { if (!this._attached) return; try { xbLog.info('worldbookBridge', 'cleanup'); } catch {} @@ -858,9 +858,9 @@ class WorldbookBridgeService { this.detachEventsForwarding(); } } - -const worldbookBridge = new WorldbookBridgeService(); - + +const worldbookBridge = new WorldbookBridgeService(); + export function initWorldbookHostBridge(options) { try { xbLog.info('worldbookBridge', 'initWorldbookHostBridge'); } catch {} try { worldbookBridge.init(options || {}); } catch {} @@ -870,30 +870,30 @@ export function cleanupWorldbookHostBridge() { try { xbLog.info('worldbookBridge', 'cleanupWorldbookHostBridge'); } catch {} try { worldbookBridge.cleanup(); } catch {} } - -if (typeof window !== 'undefined') { - Object.assign(window, { - xiaobaixWorldbookService: worldbookBridge, - initWorldbookHostBridge, - cleanupWorldbookHostBridge, - setWorldbookBridgeOrigins: (origins) => worldbookBridge.setAllowedOrigins(origins) - }); - try { initWorldbookHostBridge({ forwardEvents: true }); } catch {} - try { - window.addEventListener('xiaobaixEnabledChanged', (e) => { - try { - const enabled = e && e.detail && e.detail.enabled === true; - if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); - } catch (_) {} - }); - document.addEventListener('xiaobaixEnabledChanged', (e) => { - try { - const enabled = e && e.detail && e.detail.enabled === true; - if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); - } catch (_) {} - }); - window.addEventListener('beforeunload', () => { try { cleanupWorldbookHostBridge(); } catch (_) {} }); - } catch (_) {} -} - - + +if (typeof window !== 'undefined') { + Object.assign(window, { + xiaobaixWorldbookService: worldbookBridge, + initWorldbookHostBridge, + cleanupWorldbookHostBridge, + setWorldbookBridgeOrigins: (origins) => worldbookBridge.setAllowedOrigins(origins) + }); + try { initWorldbookHostBridge({ forwardEvents: true }); } catch {} + try { + window.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); + } catch (_) {} + }); + document.addEventListener('xiaobaixEnabledChanged', (e) => { + try { + const enabled = e && e.detail && e.detail.enabled === true; + if (enabled) initWorldbookHostBridge({ forwardEvents: true }); else cleanupWorldbookHostBridge(); + } catch (_) {} + }); + window.addEventListener('beforeunload', () => { try { cleanupWorldbookHostBridge(); } catch (_) {} }); + } catch (_) {} +} + + diff --git a/bridges/wrapper-iframe.js b/bridges/wrapper-iframe.js index 42e1056..5d59b33 100644 --- a/bridges/wrapper-iframe.js +++ b/bridges/wrapper-iframe.js @@ -1,105 +1,105 @@ -(function(){ - function defineCallGenerate(){ - function sanitizeOptions(options){ - try{ - return JSON.parse(JSON.stringify(options,function(k,v){return(typeof v==='function')?undefined:v})) - }catch(_){ - try{ - const seen=new WeakSet(); - const clone=(val)=>{ - if(val===null||val===undefined)return val; - const t=typeof val; - if(t==='function')return undefined; - if(t!=='object')return val; - if(seen.has(val))return undefined; - seen.add(val); - if(Array.isArray(val)){ - const arr=[];for(let i=0;i{ + if(val===null||val===undefined)return val; + const t=typeof val; + if(t==='function')return undefined; + if(t!=='object')return val; + if(seen.has(val))return undefined; + seen.add(val); + if(Array.isArray(val)){ + const arr=[];for(let i=0;i btoa(unescape(encodeURIComponent(text))); + +class StorageFile { + constructor(filename, opts = {}) { + this.filename = filename; + this.cache = null; + this._loading = null; + this._dirtyVersion = 0; + this._savedVersion = 0; + this._saving = false; + this._pendingSave = false; + this._retryCount = 0; + this._retryTimer = null; + this._maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : 5; + const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : 2000; + this._saveDebounced = debounce(() => this.saveNow(), debounceMs); + } + + async load() { + if (this.cache !== null) return this.cache; + if (this._loading) return this._loading; + + this._loading = (async () => { + try { + const res = await fetch(`/user/files/${this.filename}`, { + headers: getRequestHeaders(), + cache: 'no-cache', + }); + if (!res.ok) { + this.cache = {}; + return this.cache; + } + const text = await res.text(); + this.cache = text ? (JSON.parse(text) || {}) : {}; + } catch { + this.cache = {}; + } finally { + this._loading = null; + } + return this.cache; + })(); + + return this._loading; + } + + async get(key, defaultValue = null) { + const data = await this.load(); + return data[key] ?? defaultValue; + } + + async set(key, value) { + const data = await this.load(); + data[key] = value; + this._dirtyVersion++; + this._saveDebounced(); + } + + async delete(key) { + const data = await this.load(); + if (key in data) { + delete data[key]; + this._dirtyVersion++; + this._saveDebounced(); + } + } + + async saveNow() { + if (this._saving) { + this._pendingSave = true; + return; + } + if (!this.cache || this._dirtyVersion === this._savedVersion) return; + + this._saving = true; + this._pendingSave = false; + const versionToSave = this._dirtyVersion; + + try { + const json = JSON.stringify(this.cache); + const base64 = toBase64(json); + const res = await fetch('/api/files/upload', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ name: this.filename, data: base64 }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + this._savedVersion = Math.max(this._savedVersion, versionToSave); + this._retryCount = 0; + if (this._retryTimer) { + clearTimeout(this._retryTimer); + this._retryTimer = null; + } + } catch (err) { + console.error('[ServerStorage] 保存失败:', err); + this._retryCount++; + const delay = Math.min(30000, 2000 * (2 ** Math.max(0, this._retryCount - 1))); + if (!this._retryTimer && this._retryCount <= this._maxRetries) { + this._retryTimer = setTimeout(() => { + this._retryTimer = null; + this.saveNow(); + }, delay); + } + } finally { + this._saving = false; + if (this._pendingSave || this._dirtyVersion > this._savedVersion) { + this._saveDebounced(); + } + } + } + + clearCache() { + this.cache = null; + this._loading = null; + } + + getCacheSize() { + if (!this.cache) return 0; + return Object.keys(this.cache).length; + } + + getCacheBytes() { + if (!this.cache) return 0; + try { + return JSON.stringify(this.cache).length * 2; + } catch { + return 0; + } + } +} + +export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json'); \ No newline at end of file diff --git a/core/slash-command.js b/core/slash-command.js index 76df0d6..8db7836 100644 --- a/core/slash-command.js +++ b/core/slash-command.js @@ -1,30 +1,30 @@ -import { getContext } from "../../../../extensions.js"; - -/** - * 执行 SillyTavern 斜杠命令 - * @param {string} command - 要执行的命令 - * @returns {Promise} 命令执行结果 - */ -export async function executeSlashCommand(command) { - try { - if (!command) return { error: "命令为空" }; - if (!command.startsWith('/')) command = '/' + command; - const { executeSlashCommands, substituteParams } = getContext(); - if (typeof executeSlashCommands !== 'function') throw new Error("executeSlashCommands 函数不可用"); - command = substituteParams(command); - const result = await executeSlashCommands(command, true); - if (result && typeof result === 'object' && result.pipe !== undefined) { - const pipeValue = result.pipe; - if (typeof pipeValue === 'string') { - try { return JSON.parse(pipeValue); } catch { return pipeValue; } - } - return pipeValue; - } - if (typeof result === 'string' && result.trim()) { - try { return JSON.parse(result); } catch { return result; } - } - return result === undefined ? "" : result; - } catch (err) { - throw err; - } -} +import { getContext } from "../../../../extensions.js"; + +/** + * 执行 SillyTavern 斜杠命令 + * @param {string} command - 要执行的命令 + * @returns {Promise} 命令执行结果 + */ +export async function executeSlashCommand(command) { + try { + if (!command) return { error: "命令为空" }; + if (!command.startsWith('/')) command = '/' + command; + const { executeSlashCommands, substituteParams } = getContext(); + if (typeof executeSlashCommands !== 'function') throw new Error("executeSlashCommands 函数不可用"); + command = substituteParams(command); + const result = await executeSlashCommands(command, true); + if (result && typeof result === 'object' && result.pipe !== undefined) { + const pipeValue = result.pipe; + if (typeof pipeValue === 'string') { + try { return JSON.parse(pipeValue); } catch { return pipeValue; } + } + return pipeValue; + } + if (typeof result === 'string' && result.trim()) { + try { return JSON.parse(result); } catch { return result; } + } + return result === undefined ? "" : result; + } catch (err) { + throw err; + } +} diff --git a/docs/COPYRIGHT b/docs/COPYRIGHT index 20be483..8e69415 100644 --- a/docs/COPYRIGHT +++ b/docs/COPYRIGHT @@ -1,73 +1,73 @@ -LittleWhiteBox (小白X) - Copyright and Attribution Requirements -================================================================ - -Copyright 2025 biex - -This software is licensed under the Apache License 2.0 -with additional custom attribution requirements. - -MANDATORY ATTRIBUTION REQUIREMENTS -================================== - -1. AUTHOR ATTRIBUTION - - The original author "biex" MUST be prominently credited in any derivative work - - This credit must appear in: - * Software user interface (visible to end users) - * Documentation and README files - * Source code headers - * About/Credits sections - * Any promotional or marketing materials - -2. PROJECT ATTRIBUTION - - The project name "LittleWhiteBox" and "小白X" must be credited - - Required attribution format: "Based on LittleWhiteBox by biex" - - Project URL must be included: https://github.com/RT15548/LittleWhiteBox - -3. SOURCE CODE DISCLOSURE - - Any modification, enhancement, or derivative work MUST be open source - - Source code must be publicly accessible under the same license terms - - All changes must be clearly documented and attributed - -4. COMMERCIAL USE - - Commercial use is permitted under the Apache License 2.0 terms - - Attribution requirements still apply for commercial use - - No additional permission required for commercial use - -5. TRADEMARK PROTECTION - - "LittleWhiteBox" and "小白X" are trademarks of the original author - - Derivative works may not use these names without explicit permission - - Alternative naming must clearly indicate the derivative nature - -VIOLATION CONSEQUENCES -===================== - -Any violation of these attribution requirements will result in: -- Immediate termination of the license grant -- Legal action for copyright infringement -- Demand for removal of infringing content - -COMPLIANCE EXAMPLES -================== - -✅ CORRECT Attribution Examples: -- "Powered by LittleWhiteBox by biex" -- "Based on LittleWhiteBox (https://github.com/RT15548/LittleWhiteBox) by biex" -- "Enhanced version of LittleWhiteBox by biex - Original: [repository URL]" - -❌ INCORRECT Examples: -- Using the code without any attribution -- Claiming original authorship -- Using "LittleWhiteBox" name for derivative works -- Commercial use without permission -- Closed-source modifications - -CONTACT INFORMATION -================== - -For licensing inquiries or attribution questions: -- Repository: https://github.com/RT15548/LittleWhiteBox -- Author: biex -- License: Apache-2.0 WITH Custom-Attribution-Requirements - -This copyright notice and attribution requirements must be included in all -copies or substantial portions of the software. +LittleWhiteBox (小白X) - Copyright and Attribution Requirements +================================================================ + +Copyright 2025 biex + +This software is licensed under the Apache License 2.0 +with additional custom attribution requirements. + +MANDATORY ATTRIBUTION REQUIREMENTS +================================== + +1. AUTHOR ATTRIBUTION + - The original author "biex" MUST be prominently credited in any derivative work + - This credit must appear in: + * Software user interface (visible to end users) + * Documentation and README files + * Source code headers + * About/Credits sections + * Any promotional or marketing materials + +2. PROJECT ATTRIBUTION + - The project name "LittleWhiteBox" and "小白X" must be credited + - Required attribution format: "Based on LittleWhiteBox by biex" + - Project URL must be included: https://github.com/RT15548/LittleWhiteBox + +3. SOURCE CODE DISCLOSURE + - Any modification, enhancement, or derivative work MUST be open source + - Source code must be publicly accessible under the same license terms + - All changes must be clearly documented and attributed + +4. COMMERCIAL USE + - Commercial use is permitted under the Apache License 2.0 terms + - Attribution requirements still apply for commercial use + - No additional permission required for commercial use + +5. TRADEMARK PROTECTION + - "LittleWhiteBox" and "小白X" are trademarks of the original author + - Derivative works may not use these names without explicit permission + - Alternative naming must clearly indicate the derivative nature + +VIOLATION CONSEQUENCES +===================== + +Any violation of these attribution requirements will result in: +- Immediate termination of the license grant +- Legal action for copyright infringement +- Demand for removal of infringing content + +COMPLIANCE EXAMPLES +================== + +✅ CORRECT Attribution Examples: +- "Powered by LittleWhiteBox by biex" +- "Based on LittleWhiteBox (https://github.com/RT15548/LittleWhiteBox) by biex" +- "Enhanced version of LittleWhiteBox by biex - Original: [repository URL]" + +❌ INCORRECT Examples: +- Using the code without any attribution +- Claiming original authorship +- Using "LittleWhiteBox" name for derivative works +- Commercial use without permission +- Closed-source modifications + +CONTACT INFORMATION +================== + +For licensing inquiries or attribution questions: +- Repository: https://github.com/RT15548/LittleWhiteBox +- Author: biex +- License: Apache-2.0 WITH Custom-Attribution-Requirements + +This copyright notice and attribution requirements must be included in all +copies or substantial portions of the software. diff --git a/docs/LICENSE.md b/docs/LICENSE.md index 9d737a0..2737c30 100644 --- a/docs/LICENSE.md +++ b/docs/LICENSE.md @@ -1,33 +1,33 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -Copyright 2025 biex - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -ADDITIONAL TERMS: - -In addition to the terms of the Apache License 2.0, the following -attribution requirement applies to any use, modification, or distribution -of this software: - -ATTRIBUTION REQUIREMENT: -If you reference, modify, or distribute any file from this project, -you must include attribution to the original author "biex" in your -project documentation, README, or credits section. - -Simple attribution format: "Based on LittleWhiteBox by biex" - -For the complete Apache License 2.0 text, see: -http://www.apache.org/licenses/LICENSE-2.0 +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright 2025 biex + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +ADDITIONAL TERMS: + +In addition to the terms of the Apache License 2.0, the following +attribution requirement applies to any use, modification, or distribution +of this software: + +ATTRIBUTION REQUIREMENT: +If you reference, modify, or distribute any file from this project, +you must include attribution to the original author "biex" in your +project documentation, README, or credits section. + +Simple attribution format: "Based on LittleWhiteBox by biex" + +For the complete Apache License 2.0 text, see: +http://www.apache.org/licenses/LICENSE-2.0 diff --git a/docs/NOTICE b/docs/NOTICE index 1d189ae..bcdb077 100644 --- a/docs/NOTICE +++ b/docs/NOTICE @@ -1,95 +1,95 @@ -LittleWhiteBox (小白X) - Third-Party Notices and Attributions -================================================================ - -This software contains code and dependencies from various third-party sources. -The following notices and attributions are required by their respective licenses. - -PRIMARY SOFTWARE -================ - -LittleWhiteBox (小白X) -Copyright 2025 biex -Licensed under Apache-2.0 WITH Custom-Attribution-Requirements -Repository: https://github.com/RT15548/LittleWhiteBox - -RUNTIME DEPENDENCIES -==================== - -This extension is designed to work with SillyTavern and relies on the following -SillyTavern modules and APIs: - -1. SillyTavern Core Framework - - Copyright: SillyTavern Contributors - - License: AGPL-3.0 - - Repository: https://github.com/SillyTavern/SillyTavern - -2. SillyTavern Extensions API - - Used modules: extensions.js, script.js - - Provides: Extension framework, settings management, event system - -3. SillyTavern Slash Commands - - Used modules: slash-commands.js, SlashCommandParser.js - - Provides: Command execution framework - -4. SillyTavern UI Components - - Used modules: popup.js, utils.js - - Provides: User interface components and utilities - -BROWSER APIS AND STANDARDS -========================== - -This software uses standard web browser APIs: -- DOM API (Document Object Model) -- Fetch API for HTTP requests -- PostMessage API for iframe communication -- Local Storage API for data persistence -- Mutation Observer API for DOM monitoring - -JAVASCRIPT LIBRARIES -==================== - -The software may interact with the following JavaScript libraries -that are part of the SillyTavern environment: - -1. jQuery - - Copyright: jQuery Foundation and contributors - - License: MIT License - - Used for: DOM manipulation and event handling - -2. Toastr (if available) - - Copyright: CodeSeven - - License: MIT License - - Used for: Notification display - -DEVELOPMENT TOOLS -================= - -The following tools were used in development (not distributed): -- Visual Studio Code -- Git version control -- Various Node.js development tools - -ATTRIBUTION REQUIREMENTS -======================== - -When distributing this software or derivative works, you must: - -1. Include this NOTICE file -2. Maintain all copyright notices in source code -3. Provide attribution to the original author "biex" -4. Include a link to the original repository -5. Comply with Apache-2.0 license requirements -6. Follow the custom attribution requirements in LICENSE.md - -DISCLAIMER -========== - -This software is provided "AS IS" without warranty of any kind. -The author disclaims all warranties, express or implied, including -but not limited to the warranties of merchantability, fitness for -a particular purpose, and non-infringement. - -For complete license terms, see LICENSE.md -For attribution requirements, see COPYRIGHT - -Last updated: 2025-01-14 +LittleWhiteBox (小白X) - Third-Party Notices and Attributions +================================================================ + +This software contains code and dependencies from various third-party sources. +The following notices and attributions are required by their respective licenses. + +PRIMARY SOFTWARE +================ + +LittleWhiteBox (小白X) +Copyright 2025 biex +Licensed under Apache-2.0 WITH Custom-Attribution-Requirements +Repository: https://github.com/RT15548/LittleWhiteBox + +RUNTIME DEPENDENCIES +==================== + +This extension is designed to work with SillyTavern and relies on the following +SillyTavern modules and APIs: + +1. SillyTavern Core Framework + - Copyright: SillyTavern Contributors + - License: AGPL-3.0 + - Repository: https://github.com/SillyTavern/SillyTavern + +2. SillyTavern Extensions API + - Used modules: extensions.js, script.js + - Provides: Extension framework, settings management, event system + +3. SillyTavern Slash Commands + - Used modules: slash-commands.js, SlashCommandParser.js + - Provides: Command execution framework + +4. SillyTavern UI Components + - Used modules: popup.js, utils.js + - Provides: User interface components and utilities + +BROWSER APIS AND STANDARDS +========================== + +This software uses standard web browser APIs: +- DOM API (Document Object Model) +- Fetch API for HTTP requests +- PostMessage API for iframe communication +- Local Storage API for data persistence +- Mutation Observer API for DOM monitoring + +JAVASCRIPT LIBRARIES +==================== + +The software may interact with the following JavaScript libraries +that are part of the SillyTavern environment: + +1. jQuery + - Copyright: jQuery Foundation and contributors + - License: MIT License + - Used for: DOM manipulation and event handling + +2. Toastr (if available) + - Copyright: CodeSeven + - License: MIT License + - Used for: Notification display + +DEVELOPMENT TOOLS +================= + +The following tools were used in development (not distributed): +- Visual Studio Code +- Git version control +- Various Node.js development tools + +ATTRIBUTION REQUIREMENTS +======================== + +When distributing this software or derivative works, you must: + +1. Include this NOTICE file +2. Maintain all copyright notices in source code +3. Provide attribution to the original author "biex" +4. Include a link to the original repository +5. Comply with Apache-2.0 license requirements +6. Follow the custom attribution requirements in LICENSE.md + +DISCLAIMER +========== + +This software is provided "AS IS" without warranty of any kind. +The author disclaims all warranties, express or implied, including +but not limited to the warranties of merchantability, fitness for +a particular purpose, and non-infringement. + +For complete license terms, see LICENSE.md +For attribution requirements, see COPYRIGHT + +Last updated: 2025-01-14 diff --git a/docs/script-docs.md b/docs/script-docs.md index 63c7045..cc999eb 100644 --- a/docs/script-docs.md +++ b/docs/script-docs.md @@ -1,1718 +1,1718 @@ -关于小白X插件核心功能: -1. 代码块渲染功能: - - SillyTavern原生只支持显示静态代码块,无法执行JavaScript或渲染HTML - - 小白X将聊天中包含HTML标签(完整的, 或单独的 - - -``` -3. 定时任务模块: - - 拓展菜单中允许设置"在对话中自动执行"的斜杠命令 - - 可以设置触发频率(每几楼层)、触发条件(AI消息后/用户消息前/每轮对话) - - 每个任务包含:名称、要执行的命令、触发间隔、触发类型 - - 注册了/xbqte命令手动触发任务: \`/xbqte 任务名称\` - - 注册了/xbset命令调整任务间隔: \`/xbset 任务名称 间隔数字\` - - 任务命令可以使用所有标准STscript斜杠命令 - -### 4. 流式静默生成 - -这是 /gen 和 /genraw 命令的流式版本,支持流式并发。SillyTavern原生不支持流式并发,插件通过会话槽位(1-10)实现通道隔离,可同时进行多个独立的流式生成任务。 - -斜杠命令 - -命令格式: -/xbgen [参数] 提示文本 // 流式版 /gen (带上下文) -/xbgenraw [参数] 提示文本 // 流式版 /genraw (纯提示) - -可用参数: -as=system|user|assistant - 消息角色,xbgen默认system,xbgenraw默认user -id=1-10 或 id=xb1-xb10 - 会话槽位,默认1号 -api=openai|claude|gemini|cohere|deepseek - 后端类型,默认跟随主API -model=模型名 - 指定模型,默认使用后端默认模型 -apiurl=URL - 自定义API地址(部分后端支持) -apipassword=密钥 - 配合apiurl使用的密码 - -返回值: 会话ID字符串 - -UI侧可用函数 - -// 获取插件对象 -const streaming = window.parent.xiaobaixStreamingGeneration; - -// 1. 获取生成文本 -streaming.getLastGeneration(sessionId) -// 参数: sessionId可选,不传则获取最后一个会话 -// 返回: 当前生成的文本字符串 - -// 2. 获取会话状态 -streaming.getStatus(sessionId) -// 参数: sessionId可选 -// 返回: {isStreaming: boolean, text: string, sessionId: string} - -// 3. 取消生成 -streaming.cancel(sessionId) -// 参数: sessionId - 要取消的会话ID - -// 4. 执行斜杠命令 -await STscript(命令字符串) -// 参数: 完整的斜杠命令 -// 返回: 会话ID - -事件监听 - -// 监听生成完成事件 -window.addEventListener('message', (e) => { - if (e.data?.type === 'xiaobaix_streaming_completed') { - const { finalText, originalPrompt, sessionId } = e.data.payload; - // finalText: 最终生成文本 - // originalPrompt: 原始提示词 - // sessionId: 会话ID - } -}); - -完整使用示例 - -单任务流程: -// 1. 开始生成 -const sessionId = await STscript('/xbgen 写一个故事'); - -// 2. 轮询显示 -const timer = setInterval(() => { - const status = streaming.getStatus(sessionId); - if (status.text) { - document.getElementById('output').textContent = status.text; - } -}, 100); - -// 3. 监听完成 -window.addEventListener('message', (e) => { - if (e.data?.type === 'xiaobaix_streaming_completed' && - e.data.payload.sessionId === sessionId) { - clearInterval(timer); - document.getElementById('output').textContent = e.data.payload.finalText; - } -}); - -// 4. 可选: 取消生成 -// streaming.cancel(sessionId); - -并发任务示例: -// 同时启动3个任务 -const task1 = await STscript('/xbgen id=1 继续剧情'); -const task2 = await STscript('/xbgenraw id=2 api=claude 总结全文'); -const task3 = await STscript('/xbgen id=3 as=user 查看其他NPC生活状态'); - -// 分别轮询显示 -const timer = setInterval(() => { - document.getElementById('story').textContent = streaming.getLastGeneration(1); - document.getElementById('trans').textContent = streaming.getLastGeneration(2); - document.getElementById('chat').textContent = streaming.getLastGeneration(3); -}, 100); - -// 监听完成 (会收到3次事件) -window.addEventListener('message', (e) => { - if (e.data?.type === 'xiaobaix_streaming_completed') { - const sessionId = e.data.payload.sessionId; - console.log(`任务${sessionId}完成:`, e.data.payload.finalText); - } -}); - -使用注意事项 - -1. 会话槽位: 不指定id默认使用1号,并发时必须指定不同id -2. 轮询频率: 建议80-200ms间隔,避免过于频繁 -3. 资源清理: 完成后记得clearInterval,长期运行可定期取消不用的会话 - -5. 以下是SillyTavern的官方STscript脚本文档,可结合小白X功能创作深度定制的SillyTavern角色卡。 ----------------------- -# STscript 语言参考 - -## 什么是STscript? - -这是一种简单但功能强大的脚本语言,可用于在不需要严肃编程的情况下扩展SillyTavern(酒馆)的功能,让您能够: - -创建迷你游戏或速通挑战 -构建AI驱动的聊天洞察 -释放您的创造力并与他人分享 -STscript基于斜杠命令引擎构建,利用命令批处理、数据管道、宏和变量。这些概念将在以下文档中详细描述。 ---- -## Hello, World! - -要运行您的第一个脚本,请打开任何SillyTavern聊天窗口,并在聊天输入栏中输入以下内容: - -``` -/pass Hello, World! | /echo -``` - -您应该会在屏幕顶部的提示框中看到消息。现在让我们逐步分析。 - -脚本是一批命令,每个命令以斜杠开头,可以带有或不带有命名和未命名参数,并以命令分隔符结束:`|`​。 - -命令按顺序依次执行,并在彼此之间传输数据。 - -​`/pass`​命令接受"Hello, World!"作为未命名参数的常量值,并将其写入管道。 -​`/echo`​命令通过管道从前一个命令接收值,并将其显示为提示通知。 - -> 提示:要查看所有可用命令的列表,请在聊天中输入`/help slash`​。 - -由于常量未命名参数和管道是可互换的,我们可以简单地将此脚本重写为: - -``` -/echo Hello, World! -``` - -‍ - -## 用户输入 - -现在让我们为脚本添加一些交互性。我们将接受用户的输入值并在通知中显示它。 - -``` -/input Enter your name | -/echo Hello, my name is {{pipe}} -``` - -​`/input`​命令用于显示一个带有指定提示的输入框,然后将输出写入管道。 -由于`/echo`​已经有一个设置输出模板的未命名参数,我们使用`{{pipe}}`​宏来指定管道值将被渲染的位置。 - -### 其他输入/输出命令 - -​`/popup (文本)`​ — 显示一个阻塞弹窗,支持简单HTML格式,例如:`/popup 我是红色的!`​。 -​`/setinput (文本)`​ — 用提供的文本替换用户输入栏的内容。 -​`/speak voice="名称" (文本)`​ — 使用选定的TTS引擎和语音映射中的角色名称朗读文本,例如 `/speak name="唐老鸭" 嘎嘎!`​。 -​`/buttons labels=["a","b"] (文本)`​ — 显示一个带有指定文本和按钮标签的阻塞弹窗。`labels`​必须是JSON序列化的字符串数组或包含此类数组的变量名。将点击的按钮标签返回到管道,如果取消则返回空字符串。文本支持简单HTML格式。 - -‍ - -#### `/popup`​和`/input`​的参数 - -​`/popup`​和`/input`​支持以下附加命名参数: - -* ​`large=on/off`​ - 增加弹窗的垂直尺寸。默认:`off`​。 -* ​`wide=on/off`​ - 增加弹窗的水平尺寸。默认:`off`​。 -* ​`okButton=字符串`​ - 添加自定义"确定"按钮文本的功能。默认:`Ok`​。 -* ​`rows=数字`​ - (仅适用于`/input`​) 增加输入控件的大小。默认:`1`​。 - -示例: - -``` -/popup large=on wide=on okButton="接受" 请接受我们的条款和条件.... -``` - -‍ - -#### /echo的参数 - -​`/echo`​支持以下附加`severity`​参数值,用于设置显示消息的样式。 - -* ​`warning`​ -* ​`error`​ -* ​`info`​ (默认) -* ​`success`​ - -示例: - -``` -/echo severity=error 发生了非常糟糕的事情。 -``` - -‍ - -## 变量 - -变量用于在脚本中存储和操作数据,可以使用命令或宏。变量可以是以下类型之一: - -* 本地变量 — 保存到当前聊天的元数据中,并且对其唯一。 -* 全局变量 — 保存到settings.json中,并在整个应用程序中存在。 - -‍ - -1. ​`/getvar name`​或`{{getvar::name}}`​ — 获取本地变量的值。 -2. ​`/setvar key=name value`​或`{{setvar::name::value}}`​ — 设置本地变量的值。 -3. ​`/addvar key=name increment`​或`{{addvar::name::increment}}`​ — 将增量添加到本地变量的值。 -4. ​`/incvar name`​或`{{incvar::name}}`​ — 将本地变量的值增加1。 -5. ​`/decvar name`​或`{{decvar::name}}`​ — 将本地变量的值减少1。 -6. ​`/getglobalvar name`​或`{{getglobalvar::name}}`​ — 获取全局变量的值。 -7. ​`/setglobalvar key=name`​或`{{setglobalvar::name::value}}`​ — 设置全局变量的值。 -8. ​`/addglobalvar key=name`​或`{{addglobalvar::name:increment}}`​ — 将增量添加到全局变量的值。 -9. ​`/incglobalvar name`​或`{{incglobalvar::name}}`​ — 将全局变量的值增加1。 -10. ​`/decglobalvar name`​或`{{decglobalvar::name}}`​ — 将全局变量的值减少1。 -11. ​`/flushvar name`​ — 删除本地变量的值。 -12. ​`/flushglobalvar name`​ — 删除全局变量的值。 - -‍ - -* 先前未定义变量的默认值是空字符串,或者如果首次在`/addvar`​、`/incvar`​、`/decvar`​命令中使用,则为零。 -* ​`/addvar`​命令中的增量执行加法或减法(如果增量和变量值都可以转换为数字),否则执行字符串连接。 -* 如果命令参数接受变量名,并且同名的本地和全局变量都存在,则本地变量优先。 -* 所有用于变量操作的斜杠命令都将结果值写入管道,供下一个命令使用。 -* 对于宏,只有"get"、"inc"和"dec"类型的宏返回值,而"add"和"set"则替换为空字符串。 - -‍ - -现在,让我们考虑以下示例: - -``` -/input What do you want to generate? | -/setvar key=SDinput | -/echo Requesting an image of {{getvar::SDinput}} | -/getvar SDinput | -/imagine -``` - -‍ - -1. 用户输入的值保存在名为SDinput的本地变量中。 -2. ​`getvar`​宏用于在`/echo`​命令中显示该值。 -3. ​`getvar`​命令用于检索变量的值并通过管道传递。 -4. 该值传递给`/imagine`​命令(由Image Generation插件提供)作为其输入提示。 - -‍ - -由于变量在脚本执行之间保存且不会刷新,您可以在其他脚本和通过宏中引用该变量,它将解析为与示例脚本执行期间相同的值。为确保值被丢弃,请在脚本中添加`/flushvar`​命令。 - -‍ - -### 数组和对象 - -变量值可以包含JSON序列化的数组或键值对(对象)。 - -‍ - -示例: - -* 数组:["apple","banana","orange"] -* 对象:{"fruits":["apple","banana","orange"]} - -‍ - -以下修改可应用于命令以处理这些变量: - -* ​`/len`​命令获取数组中的项目数量。 -* ​`index=数字/字符串`​命名参数可以添加到`/getvar`​或`/setvar`​及其全局对应项,以通过数组的零基索引或对象的字符串键获取或设置子值。 - - * 如果在不存在的变量上使用数字索引,该变量将被创建为空数组`[]`​。 - * 如果在不存在的变量上使用字符串索引,该变量将被创建为空对象`{}`​。 -* `/addvar`​和`/addglobalvar`​命令支持将新值推送到数组类型的变量。 - -‍ - -## 流程控制 - 条件 - -您可以使用`/if`​命令创建条件表达式,根据定义的规则分支执行。 - -​`/if left=valueA right=valueB rule=comparison else="(false时执行的命令)" "(true时执行的命令)"`​ - -让我们看一下以下示例: - -``` -/input What's your favorite drink? | -/if left={{pipe}} right="black tea" rule=eq else="/echo You shall not pass \| /abort" "/echo Welcome to the club, \{\{user\}\}" -``` - -此脚本根据用户输入与所需值进行评估,并根据输入值显示不同的消息。 - -‍ - -### ​`/if`​的参数 - -1. `left`​是第一个操作数。我们称之为A。 -2. ​`right`​是第二个操作数。我们称之为B。 -3. ​`rule`​是要应用于操作数的操作。 -4. ​`else`​是可选的子命令字符串,如果布尔比较结果为false,则执行这些子命令。 -5. 未命名参数是如果布尔比较结果为true,则执行的子命令。 - -‍ - -操作数值按以下顺序评估: - -1. 数字字面量 -2. 本地变量名 -3. 全局变量名 -4. 字符串字面量 - -‍ - -命名参数的字符串值可以用引号转义,以允许多词字符串。然后丢弃引号。 - -‍ - -### 布尔操作 - -支持的布尔比较规则如下。应用于操作数的操作结果为true或false值。 - -1. ​`eq`​ (等于) => A = B -2. ​`neq`​ (不等于) => A != B -3. ​`lt`​ (小于) => A < B -4. ​`gt`​ (大于) => A > B -5. ​`lte`​ (小于或等于) => A <= B -6. ​`gte`​ (大于或等于) => A >= B -7. ​`not`​ (一元否定) => !A -8. ​`in`​ (包含子字符串) => A包含B,不区分大小写 -9. `nin`​ (不包含子字符串) => A不包含B,不区分大小写 - -‍ - -### 子命令 - -子命令是包含要执行的斜杠命令列表的字符串。 - -1. 要在子命令中使用命令批处理,命令分隔符应该被转义(见下文)。 -2. 由于宏值在进入条件时执行,而不是在执行子命令时执行,因此可以额外转义宏,以延迟其评估到子命令执行时间。 -3. 子命令执行的结果通过管道传递给`/if`​之后的命令。 -4. 遇到`/abort`​命令时,脚本执行中断。 - -‍ - -​`/if`​命令可以用作三元运算符。以下示例将在变量`a`​等于5时将"true"字符串传递给下一个命令,否则传递"false"字符串。 - -``` -/if left=a right=5 rule=eq else="/pass false" "/pass true" | -/echo -``` - -‍ - -## 转义序列 - -### 宏 - -宏的转义方式与之前相同。但是,使用闭包时,您需要比以前少得多地转义宏。可以转义两个开始的大括号,或者同时转义开始和结束的大括号对。 - -``` -/echo \{\{char}} | -/echo \{\{char\}\} -``` - -### 管道 - -闭包中的管道不需要转义(当用作命令分隔符时)。在任何您想使用字面管道字符而不是命令分隔符的地方,您都需要转义它。 - -``` -/echo title="a\|b" c\|d | -/echo title=a\|b c\|d | -``` - -使用解析器标志STRICT_ESCAPING,您不需要在引用值中转义管道。 - -``` -/parser-flag STRICT_ESCAPING | -/echo title="a|b" c\|d | -/echo title=a\|b c\|d | -``` - -### 引号 - -要在引用值内使用字面引号字符,必须转义该字符。 - -``` -/echo title="a \"b\" c" d "e" f -``` - -### 空格 - -要在命名参数的值中使用空格,您必须将值用引号括起来,或者转义空格字符。 - -``` -/echo title="a b" c d | -/echo title=a\ b c d -``` - -### 闭包分隔符 - -如果您想使用用于标记闭包开始或结束的字符组合,您必须使用单个反斜杠转义序列。 - -``` -/echo \{: | -/echo \:} -``` - -## 管道断开器 - -``` -|| -``` - -为了防止前一个命令的输出自动注入为下一个命令的未命名参数,在两个命令之间放置双管道。 - -``` -/echo we don't want to pass this on || -/world -``` - -‍ - -## 闭包 - -``` -{: ... :} -``` - -闭包(块语句、lambda、匿名函数,无论您想叫它什么)是一系列包装在`{:`​和`:}`​之间的命令,只有在代码的那部分被执行时才会被评估。 - -### 子命令 - -闭包使使用子命令变得更加容易,并且不需要转义管道和宏。 - -``` -// 不使用闭包的if | -/if left=1 rule=eq right=1 - else=" - /echo not equal \| - /return 0 - " - /echo equal \| - /return \{\{pipe}} - -// 使用闭包的if | -/if left=1 rule=eq right=1 - else={: - /echo not equal | - /return 0 - :} - {: - /echo equal | - /return {{pipe}} - :} -``` - -### 作用域 - -闭包有自己的作用域并支持作用域变量。作用域变量用`/let`​声明,它们的值用`/var`​设置和获取。获取作用域变量的另一种方法是`{{var::}}`​宏。 - -``` -/let x | -/let y 2 | -/var x 1 | -/var y | -/echo x is {{var::x}} and y is {{pipe}}. -``` - -在闭包内,您可以访问在同一闭包或其祖先之一中声明的所有变量。您无法访问在闭包的后代中声明的变量。 -如果声明的变量与闭包祖先之一中声明的变量同名,则在此闭包及其后代中无法访问祖先变量。 - -``` -/let x this is root x | -/let y this is root y | -/return {: - /echo called from level-1: x is "{{var::x}}" and y is "{{var::y}}" | - /delay 500 | - /let x this is level-1 x | - /echo called from level-1: x is "{{var::x}}" and y is "{{var::y}}" | - /delay 500 | - /return {: - /echo called from level-2: x is "{{var::x}}" and y is "{{var::y}}" | - /let x this is level-2 x | - /echo called from level-2: x is "{{var::x}}" and y is "{{var::y}}" | - /delay 500 - :}() -:}() | -/echo called from root: x is "{{var::x}}" and y is "{{var::y}}" -``` - -### 命名闭包 - -``` -/let x {: ... :} | /:x -``` - -闭包可以分配给变量(仅限作用域变量),以便稍后调用或用作子命令。 - -``` -/let myClosure {: - /echo this is my closure -:} | -/:myClosure -``` - -``` -/let myClosure {: - /echo this is my closure | - /delay 500 -:} | -/times 3 {{var::myClosure}} -``` - -​`/:`​也可以用于执行快速回复,因为它只是`/run`​的简写。 - -``` -/:QrSetName.QrButtonLabel | -/run QrSetName.QrButtonLabel -``` - -### 闭包参数 - -命名闭包可以接受命名参数,就像斜杠命令一样。参数可以有默认值。 - -``` -/let myClosure {: a=1 b= - /echo a is {{var::a}} and b is {{var::b}} -:} | -/:myClosure b=10 -``` - -### 闭包和管道参数 - -父闭包的管道值不会自动注入到子闭包的第一个命令中。 -您仍然可以使用`{{pipe}}`​显式引用父级的管道值,但如果您将闭包内第一个命令的未命名参数留空,则该值不会自动注入。 - -``` -/* 这曾经尝试将模型更改为"foo" - 因为来自循环外部/echo的值"foo" - 被注入到循环内部的/model命令中。 - 现在它将简单地回显当前模型,而不 - 尝试更改它。 -*/ -/echo foo | -/times 2 {: - /model | - /echo | -:} | -``` - -``` -/* 您仍然可以通过显式使用{{pipe}}宏 - 来重现旧行为。 -*/ -/echo foo | -/times 2 {: - /model {{pipe}} | - /echo | -:} | -``` - -### 立即执行闭包 - -``` -{: ... :}() -``` - -闭包可以立即执行,这意味着它们将被替换为其返回值。这在不存在对闭包的显式支持的地方很有用,并且可以缩短一些原本需要大量中间变量的命令。 - -``` -// 不使用闭包的两个字符串长度比较 | -/len foo | -/var lenOfFoo {{pipe}} | -/len bar | -/var lenOfBar {{pipe}} | -/if left={{var::lenOfFoo}} rule=eq right={{var:lenOfBar}} /echo yay! -``` - -``` -// 使用立即执行闭包的相同比较 | -/if left={:/len foo:}() rule=eq right={:/len bar:}() /echo yay! -``` - -除了运行保存在作用域变量中的命名闭包外,`/run`​命令还可用于立即执行闭包。 - -``` -/run {: - /add 1 2 3 4 | -:} | -/echo | -``` - -‍ - -## 注释 - -``` -// ... | /# ... -``` - -注释是脚本代码中的人类可读解释或注解。注释不会中断管道。 - -``` -// 这是一条注释 | -/echo foo | -/# 这也是一条注释 -``` - -### 块注释 - -块注释可用于快速注释掉多个命令。它们不会在管道上终止。 - -``` -/echo foo | -/* -/echo bar | -/echo foobar | -*/ -/echo foo again | -``` - -‍ - -## 流程控制 - -### 循环:`/while`​和`/times`​ - -如果您需要在循环中运行某个命令,直到满足特定条件,请使用`/while`​命令。 - -``` -/while left=valueA right=valueB rule=operation guard=on "commands" -``` - -在循环的每一步,它比较变量A的值与变量B的值,如果条件产生true,则执行引号中包含的任何有效斜杠命令,否则退出循环。此命令不向输出管道写入任何内容。 - -#### - -​`/while`​的参数 - -可用的布尔比较集合、变量处理、字面值和子命令与`/if`​命令相同。 - -可选的`guard`​命名参数(默认为`on`​)用于防止无限循环,将迭代次数限制为100。要禁用并允许无限循环,设置`guard=off`​。 - -此示例将1添加到`i`​的值,直到达到10,然后输出结果值(在本例中为10)。 - -``` -/setvar key=i 0 | -/while left=i right=10 rule=lt "/addvar key=i 1" | -/echo {{getvar::i}} | -/flushvar i -``` - -‍ - -#### `/times`​的参数 - -运行指定次数的子命令。 - -​`/times (重复次数) "(命令)"`​ – 引号中包含的任何有效斜杠命令重复指定次数,例如 `/setvar key=i 1 | /times 5 "/addvar key=i 1"`​ 将1添加到"i"的值5次。 - -* ​`{{timesIndex}}`​被替换为迭代次数(从零开始),例如 `/times 4 "/echo {{timesIndex}}"`​ 回显数字0到4。 -* 循环默认限制为100次迭代,传递`guard=off`​可禁用此限制。 - -‍ - -### 跳出循环和闭包 - -``` -/break | -``` - -​`/break`​命令可用于提前跳出循环(`/while`​或`/times`​)或闭包。`/break`​的未命名参数可用于传递与当前管道不同的值。 -​`/break`​目前在以下命令中实现: - -* ​`/while`​ - 提前退出循环 -* ​`/times`​ - 提前退出循环 -* ​`/run`​(使用闭包或通过变量的闭包)- 提前退出闭包 -* ​`/:`​(使用闭包)- 提前退出闭包 - -``` -/times 10 {: - /echo {{timesIndex}} - /delay 500 | - /if left={{timesIndex}} rule=gt right=3 {: - /break - :} | -:} | -``` - -``` -/let x {: iterations=2 - /if left={{var::iterations}} rule=gt right=10 {: - /break too many iterations! | - :} | - /times {{var::iterations}} {: - /delay 500 | - /echo {{timesIndex}} | - :} | -:} | -/:x iterations=30 | -/echo the final result is: {{pipe}} -``` - -``` -/run {: - /break 1 | - /pass 2 | -:} | -/echo pipe will be one: {{pipe}} | -``` - -``` -/let x {: - /break 1 | - /pass 2 | -:} | -/:x | -/echo pipe will be one: {{pipe}} | -``` - -# - -## 数学运算 - -* 以下所有操作都接受一系列数字或变量名,并将结果输出到管道。 -* 无效操作(如除以零)以及导致NaN值或无穷大的操作返回零。 -* 乘法、加法、最小值和最大值接受无限数量的由空格分隔的参数。 -* 减法、除法、幂运算和模运算接受由空格分隔的两个参数。 -* 正弦、余弦、自然对数、平方根、绝对值和舍入接受一个参数。 - -操作列表: - -1. ​`/add (a b c d)`​ – 执行一组值的加法,例如 `/add 10 i 30 j`​ -2. ​`/mul (a b c d)`​ – 执行一组值的乘法,例如 `/mul 10 i 30 j`​ -3. ​`/max (a b c d)`​ – 返回一组值中的最大值,例如 `/max 1 0 4 k`​ -4. ​`/min (a b c d)`​ – 返回一组值中的最小值,例如 `/min 5 4 i 2`​ -5. ​`/sub (a b)`​ – 执行两个值的减法,例如 `/sub i 5`​ -6. ​`/div (a b)`​ – 执行两个值的除法,例如 `/div 10 i`​ -7. ​`/mod (a b)`​ – 执行两个值的模运算,例如 `/mod i 2`​ -8. ​`/pow (a b)`​ – 执行两个值的幂运算,例如 `/pow i 2`​ -9. ​`/sin (a)`​ – 执行一个值的正弦运算,例如 `/sin i`​ -10. ​`/cos (a)`​ – 执行一个值的余弦运算,例如 `/cos i`​ -11. ​`/log (a)`​ – 执行一个值的自然对数运算,例如 `/log i`​ -12. ​`/abs (a)`​ – 执行一个值的绝对值运算,例如 `/abs -10`​ -13. ​`/sqrt (a)`​– 执行一个值的平方根运算,例如 `/sqrt 9`​ -14. ​`/round (a)`​ – 执行一个值的四舍五入到最接近整数的运算,例如 `/round 3.14`​ -15. `/rand (round=round|ceil|floor from=number=0 to=number=1)`​ – 返回一个介于from和to之间的随机数,例如 `/rand`​ 或 `/rand 10`​ 或 `/rand from=5 to=10`​。范围是包含的。返回的值将包含小数部分。使用`round`​命名参数获取整数值,例如 `/rand round=ceil`​ 向上舍入,`round=floor`​ 向下舍入,`round=round`​ 舍入到最接近的值。 - -‍ - -### 示例1:获取半径为50的圆的面积。 - -``` -/setglobalvar key=PI 3.1415 | -/setvar key=r 50 | -/mul r r PI | -/round | -/echo Circle area: {{pipe}} -``` - -### 示例2:计算5的阶乘。 - -``` -/setvar key=input 5 | -/setvar key=i 1 | -/setvar key=product 1 | -/while left=i right=input rule=lte "/mul product i \| /setvar key=product \| /addvar key=i 1" | -/getvar product | -/echo Factorial of {{getvar::input}}: {{pipe}} | -/flushvar input | -/flushvar i | -/flushvar product -``` - -‍ - -## 使用LLM - -脚本可以使用以下命令向您当前连接的LLM API发出请求: - -* ​`/gen (提示)`​ — 使用为所选角色提供的提示生成文本,并包含聊天消息。 -* ​`/genraw (提示)`​ — 仅使用提供的提示生成文本,忽略当前角色和聊天。 -* `/trigger`​ — 触发正常生成(相当于点击"发送"按钮)。如果在群聊中,您可以选择提供基于1的群组成员索引或角色名称让他们回复,否则根据群组设置触发群组回合。 - -### `/gen`​和`/genraw`​的参数 - -``` -/genraw lock=on/off stop=[] instruct=on/off (Prompt) -``` - -‍ - -* ​`lock`​ — 可以是`on`​或`off`​。指定生成过程中是否应阻止用户输入。默认:`off`​。 -* ​`stop`​ — JSON序列化的字符串数组。仅为此生成添加自定义停止字符串(如果API支持)。默认:无。 -* ​`instruct`​(仅`/genraw`​)— 可以是`on`​或`off`​。允许在输入提示上使用指令格式(如果启用了指令模式且API支持)。设置为`off`​强制使用纯提示。默认:`on`​。 -* ​`as`​(用于文本完成API)— 可以是`system`​(默认)或`char`​。定义最后一行提示将如何格式化。`char`​将使用角色名称,`system`​将使用无名称或中性名称。 - -‍ - -生成的文本然后通过管道传递给下一个命令,可以保存到变量或使用I/O功能显示: - -``` -/genraw Write a funny message from Cthulhu about taking over the world. Use emojis. | -/popup

Cthulhu says:

{{pipe}}
-``` - -或者将生成的消息作为角色的回复插入: - -``` -/genraw You have been memory wiped, your name is now Lisa and you're tearing me apart. You're tearing me apart Lisa! | -/sendas name={{char}} {{pipe}} -``` - -‍ - -## 提示注入 - -脚本可以添加自定义LLM提示注入,本质上相当于无限的作者注释。 - -* ​`/inject (文本)`​ — 将任何文本插入到当前聊天的正常LLM提示中,并需要一个唯一标识符。保存到聊天元数据。 -* ​`/listinjects`​ — 在系统消息中显示脚本为当前聊天添加的所有提示注入列表。 -* ​`/flushinjects`​ — 删除脚本为当前聊天添加的所有提示注入。 -* ​`/note (文本)`​ — 设置当前聊天的作者注释值。保存到聊天元数据。 -* ​`/interval`​ — 设置当前聊天的作者注释插入间隔。 -* ​`/depth`​ — 设置聊天内位置的作者注释插入深度。 -* `/position`​ — 设置当前聊天的作者注释位置。 - -‍ - -### `/inject`​的参数 - -``` -/inject id=IdGoesHere position=chat depth=4 My prompt injection -``` - -​`id`​ — 标识符字符串或对变量的引用。使用相同ID的连续`/inject`​调用将覆盖先前的文本注入。必需参数。 -​`position`​ — 设置注入的位置。默认:`after`​。可能的值: -​`after`​:在主提示之后。 -​`before`​:在主提示之前。 -​`chat`​:在聊天中。 -​`depth`​ — 设置聊天内位置的注入深度。0表示在最后一条消息之后插入,1表示在最后一条消息之前,依此类推。默认:4。 -未命名参数是要注入的文本。空字符串将取消设置提供的标识符的先前值。 - -‍ - -## 访问聊天消息 - -### 读取消息 - -您可以使用`/messages`​命令访问当前选定聊天中的消息。 - -``` -/messages names=on/off start-finish -``` - -* `names`​参数用于指定是否要包含角色名称,默认:`on`​。 - -* 在未命名参数中,它接受消息索引或start-finish格式的范围。范围是包含的! -* 如果范围不可满足,即无效索引或请求的消息数量超过存在的消息数量,则返回空字符串。 -* 从提示中隐藏的消息(由幽灵图标表示)从输出中排除。 -* 如果您想知道最新消息的索引,请使用`{{lastMessageId}}`​宏,而`{{lastMessage}}`​将获取消息本身。 - -要计算范围的起始索引,例如,当您需要获取最后N条消息时,请使用变量减法。此示例将获取聊天中的最后3条消息: - -``` -/setvar key=start {{lastMessageId}} | -/addvar key=start -2 | -/messages names=off {{getvar::start}}-{{lastMessageId}} | -/setinput -``` - -‍ - -### 发送消息 - -脚本可以作为用户、角色、人物、中立叙述者发送消息,或添加评论。 - -1. ​`/send (文本)`​ — 作为当前选定的人物添加消息。 -2. ​`/sendas name=charname (文本)`​ — 作为任何角色添加消息,通过其名称匹配。`name`​参数是必需的。使用`{{char}}`​宏作为当前角色发送。 -3. ​`/sys (文本)`​ — 添加来自中立叙述者的消息,不属于用户或角色。显示的名称纯粹是装饰性的,可以使用`/sysname`​命令自定义。 -4. ​`/comment (文本)`​ — 添加在聊天中显示但在提示中不可见的隐藏评论。 -5. ​`/addswipe (文本)`​ — 为最后一条角色消息添加滑动。不能为用户或隐藏消息添加滑动。 -6. ​`/hide (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,从提示中隐藏一条或多条消息。 -7. ​`/unhide (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,将一条或多条消息返回到提示中。 - -`/send`​、`/sendas`​、`/sys`​和`/comment`​命令可选地接受一个名为`at`​的命名参数,其值为基于零的数字(或包含此类值的变量名),指定消息插入的确切位置。默认情况下,新消息插入在聊天日志的末尾。 - -这将在对话历史的开头插入一条用户消息: - -``` -/send at=0 Hi, I use Linux. -``` - -‍ - -### 删除消息 - -这些命令具有潜在的破坏性,没有"撤销"功能。如果您不小心删除了重要内容,请检查/backups/文件夹。 - -1. ​`/cut (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,从聊天中剪切一条或多条消息。 -2. ​`/del (数字)`​ — 从聊天中删除最后N条消息。 -3. ​`/delswipe (基于1的滑动ID)`​ — 根据提供的基于1的滑动ID,从最后一条角色消息中删除滑动。 -4. ​`/delname (角色名称)`​ — 删除当前聊天中属于指定名称角色的所有消息。 -5. `/delchat`​ — 删除当前聊天。 - -‍ - -## 世界信息命令 - -世界信息(也称为Lorebook)是一种高度实用的工具,用于动态将数据插入提示。有关更详细的解释,请参阅专门的页面:==世界信息==。 - -1. ​`/getchatbook`​ – 获取聊天绑定的世界信息文件名称,如果未绑定则创建一个新的,并通过管道传递。 -2. ​`/findentry file=bookName field=fieldName [text]`​ – 使用字段值与提供的文本的模糊匹配,从指定文件(或指向文件名的变量)中查找记录的UID(默认字段:key),并通过管道传递UID,例如 `/findentry file=chatLore field=key Shadowfang`​。 -3. ​`/getentryfield file=bookName field=field [UID]`​ – 获取指定世界信息文件(或指向文件名的变量)中UID记录的字段值(默认字段:content),并通过管道传递值,例如 `/getentryfield file=chatLore field=content 123`​。 -4. ​`/setentryfield file=bookName uid=UID field=field [text]`​ – 设置指定世界信息文件(或指向文件名的变量)中UID(或指向UID的变量)记录的字段值(默认字段:content)。要为key字段设置多个值,请使用逗号分隔的列表作为文本值,例如 `/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon`​。 -5. `/createentry file=bookName key=keyValue [content text]`​ – 在指定文件(或指向文件名的变量)中创建一个新记录,带有key和content(这两个参数都是可选的),并通过管道传递UID,例如 `/createentry file=chatLore key=Shadowfang The sword of the king`​。 - -### 有效条目字段 - -|字段|UI元素|值类型| -| ------| ---------------| :-----------: | -|​`content`​|内容|字符串| -|​`comment`​|标题/备忘录|字符串| -|​`key`​|主关键词|字符串列表| -|​`keysecondary`​|可选过滤器|字符串列表| -|​`constant`​|常量状态|布尔值(1/0)| -|​`disable`​|禁用状态|布尔值(1/0)| -|​`order`​|顺序|数字| -|​`selectiveLogic`​|逻辑|(见下文)| -|​`excludeRecursion`​|不可递归|布尔值(1/0)| -|​`probability`​|触发%|数字(0-100)| -|​`depth`​|深度|数字(0-999)| -|​`position`​|位置|(见下文)| -|​`role`​|深度角色|(见下文)| -|​`scanDepth`​|扫描深度|数字(0-100)| -|​`caseSensitive`​|caseSensitive|布尔值(1/0)| -|​`matchWholeWords`​|匹配整词|布尔值(1/0)| - -‍ - -#### 逻辑值 - -* 0 = AND ANY -* 1 = NOT ALL -* 2 = NOT ANY -* 3 = AND ALL - -#### 位置值 - -* 0 = 主提示之前 -* 1 = 主提示之后 -* 2 = 作者注释顶部 -* 3 = 作者注释底部 -* 4 = 聊天中的深度 -* 5 = 示例消息顶部 -* 6 = 示例消息底部 - -#### 角色值(仅限位置 = 4) - -* 0 = 系统 -* 1 = 用户 -* 2 = 助手 - -‍ - -### 示例1:通过关键字从聊天知识库中读取内容 - -``` -/getchatbook | /setvar key=chatLore | -/findentry file={{getvar::chatLore}} field=key Shadowfang | -/getentryfield file={{getvar::chatLore}} field=key | -/echo -``` - -### 示例2:创建带有关键字和内容的聊天知识库条目 - -``` -/getchatbook | /setvar key=chatLore | -/createentry file={{getvar::chatLore}} key="Milla" Milla Basset is a friend of Lilac and Carol. She is a hush basset puppy who possesses the power of alchemy. | -/echo -``` - -### 示例3:用聊天中的新信息扩展现有知识库条目 - -``` -/getchatbook | /setvar key=chatLore | -/findentry file={{getvar::chatLore}} field=key Milla | -/setvar key=millaUid | -/getentryfield file={{getvar::chatLore}} field=content | -/setvar key=millaContent | -/gen lock=on Tell me more about Milla Basset based on the provided conversation history. Incorporate existing information into your reply: {{getvar::millaContent}} | -/setvar key=millaContent | -/echo New content: {{pipe}} | -/setentryfield file={{getvar::chatLore}} uid=millaUid field=content {{getvar::millaContent}} -``` - -‍ - -## 文本操作 - -有各种有用的文本操作实用命令,可用于各种脚本场景。 - -1. ​`/trimtokens`​ — 将输入修剪为从开始或从结尾指定数量的文本标记,并将结果输出到管道。 -2. ​`/trimstart`​ — 将输入修剪到第一个完整句子的开始,并将结果输出到管道。 -3. ​`/trimend`​ — 将输入修剪到最后一个完整句子的结尾,并将结果输出到管道。 -4. ​`/fuzzy`​ — 对输入文本执行与字符串列表的模糊匹配,将最佳字符串匹配输出到管道。 -5. `/regex name=scriptName [text]`​ — 为指定文本执行正则表达式扩展中的正则表达式脚本。脚本必须启用。 - -### `/trimtokens`​的参数 - -``` -/trimtokens limit=number direction=start/end (input) -``` - -1. ​`direction`​设置修剪的方向,可以是`start`​或`end`​。默认:`end`​。 -2. ​`limit`​设置输出中保留的标记数量。也可以指定包含数字的变量名。必需参数。 -3. 未命名参数是要修剪的输入文本。 - -### `/fuzzy`​的参数 - -``` -/fuzzy list=["candidate1","candidate2"] (input) -``` - -1. ​`list`​是包含候选项的JSON序列化字符串数组。也可以指定包含列表的变量名。必需参数。 -2. 未命名参数是要匹配的输入文本。输出是与输入最接近匹配的候选项之一。 - -‍ - -## 自动完成 - -* 自动完成在聊天输入和大型快速回复编辑器中都已启用。 -* 自动完成在您的输入中的任何位置都有效。即使有多个管道命令和嵌套闭包。 -* 自动完成支持三种查找匹配命令的方式(用户设置 -> STscript匹配)。 - -‍ - -1. **以...开头** "旧"方式。只有以输入的值精确开头的命令才会显示。 -2. **包含** 所有包含输入值的命令都会显示。例如:当输入`/delete`​时,命令`/qr-delete`​和`/qr-set-delete`​将显示在自动完成列表中(`/qr-delete`​,`/qr-set-delete`​)。 -3. 模糊 所有可以与输入值模糊匹配的命令都会显示。例如:当输入`/seas`​时,命令`/sendas`​将显示在自动完成列表中(`/sendas`​)。 - -‍ - -* 命令参数也受自动完成支持。列表将自动显示必需参数。对于可选参数,按Ctrl+Space打开可用选项列表。 -* 当输入`/:`​执行闭包或QR时,自动完成将显示作用域变量和QR的列表。 - 自动完成对宏(在斜杠命令中)有有限支持。输入`{{`​获取可用宏的列表。 -* 使用上下箭头键从自动完成选项列表中选择一个选项。 -* 按Enter或Tab或点击一个选项将该选项放置在光标处。 -* 按Escape关闭自动完成列表。 -* 按Ctrl+Space打开自动完成列表或切换所选选项的详细信息。 - -‍ - -## 解析器标志 - -``` -/parser-flag -``` - -解析器接受标志来修改其行为。这些标志可以在脚本中的任何点切换开关,所有后续输入将相应地进行评估。 -您可以在用户设置中设置默认标志。 - -‍ - -### 严格转义 - -``` -/parser-flag STRICT_ESCAPING on | -``` - -启用`STRICT_ESCAPING`​后的变化如下。 - -#### 管道 - -引用值中的管道不需要转义。 - -``` -/echo title="a|b" c\|d -``` - -#### 反斜杠 - -符号前的反斜杠可以被转义,以提供后面跟着功能符号的字面反斜杠。 - -``` -// 这将回显"foo \",然后回显"bar" | -/echo foo \\| -/echo bar - -/echo \\| -/echo \\\| -``` - -### 替换变量宏 - -``` -/parser-flag REPLACE_GETVAR on | -``` - -此标志有助于避免当变量值包含可能被解释为宏的文本时发生双重替换。`{{var::}}`​宏最后被替换,并且在结果文本/变量值上不会发生进一步的替换。 - -将所有`{{getvar::}}`​和`{{getglobalvar::}}`​宏替换为`{{var::}}`​。在幕后,解析器将在带有替换宏的命令之前插入一系列命令执行器: - -* 调用`/let`​保存当前`{{pipe}}`​到作用域变量 -* 调用`/getvar`​或`/getglobalvar`​获取宏中使用的变量 -* 调用`/let`​将检索到的变量保存到作用域变量 -* 调用`/return`​并带有保存的`{{pipe}}`​值,以恢复下一个命令的正确管道值 - -``` -// 以下将回显最后一条消息的id/编号 | -/setvar key=x \{\{lastMessageId}} | -/echo {{getvar::x}} -``` - -``` -// 这将回显字面文本{{lastMessageId}} | -/parser-flag REPLACE_GETVAR | -/setvar key=x \{\{lastMessageId}} | -/echo {{getvar::x}} -``` - -‍ - -## 快速回复:脚本库和自动执行 - -快速回复是一个内置的SillyTavern扩展,提供了一种简单的方式来存储和执行您的脚本。 - -### 配置快速回复 - -要开始使用,请打开扩展面板(堆叠块图标),并展开快速回复菜单。 - -**快速回复默认是禁用的,您需要先启用它们。** 然后您将看到一个栏出现在聊天输入栏上方。 - -您可以设置显示的按钮文本标签(我们建议使用表情符号以简洁)和点击按钮时将执行的脚本。 - -插槽数量由 **"插槽数量"** 设置控制(最大=100),根据您的需要调整它,完成后点击"应用"。 - - **"自动注入用户输入"** 建议在使用STscript时禁用,否则可能会干扰您的输入,请在脚本中使用`{{input}}`​宏获取输入栏的当前值。 - -快速回复预设允许有多组预定义的快速回复,可以手动切换或使用`/qrset(预设名称)`​命令切换。切换到不同的预设前,不要忘记点击"更新"以将您的更改写入当前使用的预设! - -‍ - -### 手动执行 - -现在您可以将第一个脚本添加到库中。选择任何空闲插槽(或创建一个),在左框中输入"点击我"设置标签,然后将以下内容粘贴到右框中: - -``` -/addvar key=clicks 1 | -/if left=clicks right=5 rule=eq else="/echo Keep going..." "/echo You did it! \| /flushvar clicks" -``` - -然后点击出现在聊天栏上方的按钮5次。每次点击将变量`clicks`​的值增加1,当值等于5时显示不同的消息并重置变量。 - -‍ - -### 自动执行 - -通过点击创建命令的`⋮`​按钮打开模态菜单。 - -在此菜单中,您可以执行以下操作: - -* 在方便的全屏编辑器中编辑脚本 -* 从聊天栏隐藏按钮,使其只能通过自动执行访问。 -* 在以下一个或多个条件下启用自动执行: - - * 应用启动 - * 向聊天发送用户消息 - * 在聊天中接收AI消息 - * 打开角色或群组聊天 - * 触发群组成员回复 - * 使用相同的自动化ID激活世界信息条目 - -* 为快速回复提供自定义工具提示(悬停在UI中的快速回复上显示的文本) -* 执行脚本进行测试 - -‍ - -只有在启用快速回复扩展时,命令才会自动执行。 - -例如,您可以通过添加以下脚本并设置为在用户消息上自动执行,在发送五条用户消息后显示一条消息。 - -``` -/addvar key=usercounter 1 | -/echo You've sent {{pipe}} messages. | -/if left=usercounter right=5 rule=gte "/echo Game over! \| /flushvar usercounter" -``` - -### 调试器 - -在扩展的快速回复编辑器中存在一个基本调试器。在脚本中的任何地方设置断点,使用`/breakpoint |`​。从QR编辑器执行脚本时,执行将在该点中断,允许您检查当前可用的变量、管道、命令参数等,并逐步执行剩余代码。 - -``` -/let x {: n=1 - /echo n is {{var::n}} | - /mul n n | -:} | -/breakpoint | -/:x n=3 | -/echo result is {{pipe}} | -``` - -### 调用过程 - -​`/run`​命令可以通过其标签调用在快速回复中定义的脚本,基本上提供了定义过程并从中返回结果的能力。这允许有可重用的脚本块,其他脚本可以引用。过程管道中的最后一个结果将传递给其后的下一个命令。 - -``` -/run ScriptLabel -``` - -让我们创建两个快速回复: - ---- - -标签: - -​`GetRandom`​ - -命令: - -``` -/pass {{roll:d100}} -``` - ---- - -标签: - -​`GetMessage`​ - -命令: - -``` -/run GetRandom | /echo Your lucky number is: {{pipe}} -``` - -点击GetMessage按钮将调用GetRandom过程,该过程将解析{{roll}}宏并将数字传递给调用者,显示给用户。 - -* 过程不接受命名或未命名参数,但可以引用与调用者相同的变量。 -* 调用过程时避免递归,因为如果处理不当,可能会产生"调用栈超出"错误。 - -#### 从不同快速回复预设调用过程 - -您可以使用`a.b`​语法从不同的快速回复预设调用过程,其中`a`​ = QR预设名称,`b`​ = QR标签名称 - -``` -/run QRpreset1.QRlabel1 -``` - -默认情况下,系统将首先查找标签为a.b的快速回复,因此如果您的标签之一字面上是"QRpreset1.QRlabel1",它将尝试运行该标签。如果找不到这样的标签,它将搜索名为"QRpreset1"的QR预设,其中有一个标记为"QRlabel1"的QR。 - -‍ - -### 快速回复管理命令 - -#### 创建快速回复 - -​`/qr-create (参数, [消息])`​ – 创建一个新的快速回复,例如:`/qr-create set=MyPreset label=MyButton /echo 123`​ - -参数: - -* ​`label`​ - 字符串 - 按钮上的文本,例如,`label=MyButton`​ -* ​`set`​ - 字符串 - QR集的名称,例如,`set=PresetName1`​ -* ​`hidden`​ - 布尔值 - 按钮是否应该隐藏,例如,`hidden=true`​ -* ​`startup`​ - 布尔值 - 应用启动时自动执行,例如,`startup=true`​ -* ​`user`​ - 布尔值 - 用户消息时自动执行,例如,`user=true`​ -* ​`bot`​ - 布尔值 - AI消息时自动执行,例如,`bot=true`​ -* ​`load`​ - 布尔值 - 聊天加载时自动执行,例如,`load=true`​ -* ​`title`​ - 布尔值 - 在按钮上显示的标题/工具提示,例如,`title="My Fancy Button"`​ - -‍ - -#### 删除快速回复 - -* ​`/qr-delete (set=string [label])`​ – 删除快速回复 - -#### 更新快速回复 - -* ​`/qr-update (参数, [消息])`​ – 更新快速回复,例如:`/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123`​ - -‍ - -参数: - -* ​`newlabel `​- 字符串 - 按钮的新文本,例如 `newlabel=MyRenamedButton`​ -* ​`label`​ - 字符串 - 按钮上的文本,例如,`label=MyButton`​ -* ​`set`​ - 字符串 - QR集的名称,例如,`set=PresetName1`​ -* ​`hidden`​ - 布尔值 - 按钮是否应该隐藏,例如,`hidden=true`​ -* ​`startup`​ - 布尔值 - 应用启动时自动执行,例如,`startup=true`​ -* ​`user`​ - 布尔值 - 用户消息时自动执行,例如,`user=true`​ -* ​`bot`​ - 布尔值 - AI消息时自动执行,例如,`bot=true`​ -* ​`load`​ - 布尔值 - 聊天加载时自动执行,例如,`load=true`​ -* ​`title`​ - 布尔值 - 在按钮上显示的标题/工具提示,例如,`title="My Fancy Button"`​ - -* `qr-get`​ - 检索快速回复的所有属性,例如:`/qr-get set=myQrSet id=42`​ - -‍ - -#### 创建或更新QR预设 - -* ​`/qr-presetupdate (参数 [标签])`​ 或 `/qr-presetadd (参数 [标签])`​ - -参数: - -* ​`enabled`​ - 布尔值 - 启用或禁用预设 -* ​`nosend`​ - 布尔值 - 禁用发送/插入用户输入(对斜杠命令无效) -* ​`before`​ - 布尔值 - 在用户输入前放置QR -* ​`slots`​ - 整数 - 插槽数量 -* ​`inject`​ - 布尔值 - 自动注入用户输入(如果禁用,使用`{{input}}`​) - -创建一个新预设(覆盖现有预设),例如:`/qr-presetadd slots=3 MyNewPreset`​ - -#### 添加QR上下文菜单 - -* `/qr-contextadd (set=string label=string chain=bool [preset name])`​ – 向QR添加上下文菜单预设,例如:`/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset`​ - -#### 删除所有上下文菜单 - -* `/qr-contextclear (set=string [label])`​ – 从QR中删除所有上下文菜单预设,例如:`/qr-contextclear set=MyPreset MyButton`​ - -#### 删除一个上下文菜单 - -* ​`/qr-contextdel (set=string label=string [preset name])`​ – 从QR中删除上下文菜单预设,例如:`/qr-contextdel set=MyPreset label=MyButton MyOtherPreset`​ - -‍ - -### 快速回复值转义 - -​`|{}`​可以在QR消息/命令中用反斜杠转义。 - -例如,使用`/qr-create label=MyButton /getvar myvar | /echo {{pipe}}`​创建一个调用`/getvar myvar | /echo {{pipe}}`​的QR。 - -‍ - -## 扩展命令 - -SillyTavern扩展(内置、可下载和第三方)可以添加自己的斜杠命令。以下只是官方扩展中功能的示例。列表可能不完整,请务必查看/help slash获取最完整的可用命令列表。 - -1. ​`/websearch`​ (查询) — 在线搜索网页片段,查找指定查询并将结果返回到管道。由Web Search扩展提供。 -2. ​`/imagine`​ (提示) — 使用提供的提示生成图像。由Image Generation扩展提供。 -3. ​`/emote`​ (精灵) — 通过模糊匹配其名称为活动角色设置精灵。由Character Expressions扩展提供。 -4. ​`/costume`​ (子文件夹) — 为活动角色设置精灵集覆盖。由Character Expressions扩展提供。 -5. ​`/music`​ (名称) — 强制更改播放的背景音乐文件,通过其名称。由Dynamic Audio扩展提供。 -6. ​`/ambient`​ (名称) — 强制更改播放的环境声音文件,通过其名称。由Dynamic Audio扩展提供。 -7. ​`/roll`​ (骰子公式) — 向聊天添加带有骰子掷出结果的隐藏消息。由D&D Dice扩展提供。 - -‍ - -## UI交互 - -脚本还可以与SillyTavern的UI交互:浏览聊天或更改样式参数。 - -### 角色导航 - -* ​`/random`​ — 打开与随机角色的聊天。 -* `/go (名称)`​ — 打开与指定名称角色的聊天。首先搜索精确名称匹配,然后按前缀,然后按子字符串。 - -### UI样式 - -1. ​`/bubble`​ — 将消息样式设置为"气泡聊天"样式。 -2. `/flat`​ — 将消息样式设置为"平面聊天"样式。 -3. `/single`​ — 将消息样式设置为"单一文档"样式。 -4. `/movingui (名称)`​ — 通过名称激活MovingUI预设。 -5. `/resetui`​ — 将MovingUI面板状态重置为其原始位置。 -6. `/panels`​ — 切换UI面板可见性:顶部栏、左侧和右侧抽屉。 -7. `/bg (名称)`​ — 使用模糊名称匹配查找并设置背景。尊重聊天背景锁定状态。 -8. `/lockbg`​ — 锁定当前聊天的背景图像。 -9. `/unlockbg`​ — 解锁当前聊天的背景图像。 - -‍ - -## 更多示例 - -### 生成聊天摘要(由@IkariDevGIT提供) - -``` -/setglobalvar key=summaryPrompt Summarize the most important facts and events that have happened in the chat given to you in the Input header. Limit the summary to 100 words or less. Your response should include nothing but the summary. | -/setvar key=tmp | -/messages 0-{{lastMessageId}} | -/trimtokens limit=3000 direction=end | -/setvar key=s1 | -/echo Generating, please wait... | -/genraw lock=on instruct=off {{instructInput}}{{newline}}{{getglobalvar::summaryPrompt}}{{newline}}{{newline}}{{instructInput}}{{newline}}{{getvar::s1}}{{newline}}{{newline}}{{instructOutput}}{{newline}}The chat summary:{{newline}} | -/setvar key=tmp | -/echo Done! | -/setinput {{getvar::tmp}} | -/flushvar tmp | -/flushvar s1 -``` - -### 按钮弹窗使用 - -``` -/setglobalvar key=genders ["boy", "girl", "other"] | -/buttons labels=genders Who are you? | -/echo You picked: {{pipe}} -``` - -### 获取第N个斐波那契数(使用比内公式) - -> 提示:将fib_no的值设置为所需的数字 - -``` -/setvar key=fib_no 5 | -/pow 5 0.5 | /setglobalvar key=SQRT5 | -/setglobalvar key=PHI 1.618033 | -/pow PHI fib_no | /div {{pipe}} SQRT5 | -/round | -/echo {{getvar::fib_no}}th Fibonacci's number is: {{pipe}} -``` - -### 递归阶乘(使用闭包) - -``` -/let fact {: n= - /if left={{var::n}} rule=gt right=1 - else={: - /return 1 - :} - {: - /sub {{var::n}} 1 | - /:fact n={{pipe}} | - /mul {{var::n}} {{pipe}} - :} -:} | - -/input Calculate factorial of: | -/let n {{pipe}} | -/:fact n={{var::n}} | -/echo factorial of {{var::n}} is {{pipe}} +关于小白X插件核心功能: +1. 代码块渲染功能: + - SillyTavern原生只支持显示静态代码块,无法执行JavaScript或渲染HTML + - 小白X将聊天中包含HTML标签(完整的, 或单独的 + + +``` +3. 定时任务模块: + - 拓展菜单中允许设置"在对话中自动执行"的斜杠命令 + - 可以设置触发频率(每几楼层)、触发条件(AI消息后/用户消息前/每轮对话) + - 每个任务包含:名称、要执行的命令、触发间隔、触发类型 + - 注册了/xbqte命令手动触发任务: \`/xbqte 任务名称\` + - 注册了/xbset命令调整任务间隔: \`/xbset 任务名称 间隔数字\` + - 任务命令可以使用所有标准STscript斜杠命令 + +### 4. 流式静默生成 + +这是 /gen 和 /genraw 命令的流式版本,支持流式并发。SillyTavern原生不支持流式并发,插件通过会话槽位(1-10)实现通道隔离,可同时进行多个独立的流式生成任务。 + +斜杠命令 + +命令格式: +/xbgen [参数] 提示文本 // 流式版 /gen (带上下文) +/xbgenraw [参数] 提示文本 // 流式版 /genraw (纯提示) + +可用参数: +as=system|user|assistant - 消息角色,xbgen默认system,xbgenraw默认user +id=1-10 或 id=xb1-xb10 - 会话槽位,默认1号 +api=openai|claude|gemini|cohere|deepseek - 后端类型,默认跟随主API +model=模型名 - 指定模型,默认使用后端默认模型 +apiurl=URL - 自定义API地址(部分后端支持) +apipassword=密钥 - 配合apiurl使用的密码 + +返回值: 会话ID字符串 + +UI侧可用函数 + +// 获取插件对象 +const streaming = window.parent.xiaobaixStreamingGeneration; + +// 1. 获取生成文本 +streaming.getLastGeneration(sessionId) +// 参数: sessionId可选,不传则获取最后一个会话 +// 返回: 当前生成的文本字符串 + +// 2. 获取会话状态 +streaming.getStatus(sessionId) +// 参数: sessionId可选 +// 返回: {isStreaming: boolean, text: string, sessionId: string} + +// 3. 取消生成 +streaming.cancel(sessionId) +// 参数: sessionId - 要取消的会话ID + +// 4. 执行斜杠命令 +await STscript(命令字符串) +// 参数: 完整的斜杠命令 +// 返回: 会话ID + +事件监听 + +// 监听生成完成事件 +window.addEventListener('message', (e) => { + if (e.data?.type === 'xiaobaix_streaming_completed') { + const { finalText, originalPrompt, sessionId } = e.data.payload; + // finalText: 最终生成文本 + // originalPrompt: 原始提示词 + // sessionId: 会话ID + } +}); + +完整使用示例 + +单任务流程: +// 1. 开始生成 +const sessionId = await STscript('/xbgen 写一个故事'); + +// 2. 轮询显示 +const timer = setInterval(() => { + const status = streaming.getStatus(sessionId); + if (status.text) { + document.getElementById('output').textContent = status.text; + } +}, 100); + +// 3. 监听完成 +window.addEventListener('message', (e) => { + if (e.data?.type === 'xiaobaix_streaming_completed' && + e.data.payload.sessionId === sessionId) { + clearInterval(timer); + document.getElementById('output').textContent = e.data.payload.finalText; + } +}); + +// 4. 可选: 取消生成 +// streaming.cancel(sessionId); + +并发任务示例: +// 同时启动3个任务 +const task1 = await STscript('/xbgen id=1 继续剧情'); +const task2 = await STscript('/xbgenraw id=2 api=claude 总结全文'); +const task3 = await STscript('/xbgen id=3 as=user 查看其他NPC生活状态'); + +// 分别轮询显示 +const timer = setInterval(() => { + document.getElementById('story').textContent = streaming.getLastGeneration(1); + document.getElementById('trans').textContent = streaming.getLastGeneration(2); + document.getElementById('chat').textContent = streaming.getLastGeneration(3); +}, 100); + +// 监听完成 (会收到3次事件) +window.addEventListener('message', (e) => { + if (e.data?.type === 'xiaobaix_streaming_completed') { + const sessionId = e.data.payload.sessionId; + console.log(`任务${sessionId}完成:`, e.data.payload.finalText); + } +}); + +使用注意事项 + +1. 会话槽位: 不指定id默认使用1号,并发时必须指定不同id +2. 轮询频率: 建议80-200ms间隔,避免过于频繁 +3. 资源清理: 完成后记得clearInterval,长期运行可定期取消不用的会话 + +5. 以下是SillyTavern的官方STscript脚本文档,可结合小白X功能创作深度定制的SillyTavern角色卡。 +---------------------- +# STscript 语言参考 + +## 什么是STscript? + +这是一种简单但功能强大的脚本语言,可用于在不需要严肃编程的情况下扩展SillyTavern(酒馆)的功能,让您能够: + +创建迷你游戏或速通挑战 +构建AI驱动的聊天洞察 +释放您的创造力并与他人分享 +STscript基于斜杠命令引擎构建,利用命令批处理、数据管道、宏和变量。这些概念将在以下文档中详细描述。 +--- +## Hello, World! + +要运行您的第一个脚本,请打开任何SillyTavern聊天窗口,并在聊天输入栏中输入以下内容: + +``` +/pass Hello, World! | /echo +``` + +您应该会在屏幕顶部的提示框中看到消息。现在让我们逐步分析。 + +脚本是一批命令,每个命令以斜杠开头,可以带有或不带有命名和未命名参数,并以命令分隔符结束:`|`​。 + +命令按顺序依次执行,并在彼此之间传输数据。 + +​`/pass`​命令接受"Hello, World!"作为未命名参数的常量值,并将其写入管道。 +​`/echo`​命令通过管道从前一个命令接收值,并将其显示为提示通知。 + +> 提示:要查看所有可用命令的列表,请在聊天中输入`/help slash`​。 + +由于常量未命名参数和管道是可互换的,我们可以简单地将此脚本重写为: + +``` +/echo Hello, World! +``` + +‍ + +## 用户输入 + +现在让我们为脚本添加一些交互性。我们将接受用户的输入值并在通知中显示它。 + +``` +/input Enter your name | +/echo Hello, my name is {{pipe}} +``` + +​`/input`​命令用于显示一个带有指定提示的输入框,然后将输出写入管道。 +由于`/echo`​已经有一个设置输出模板的未命名参数,我们使用`{{pipe}}`​宏来指定管道值将被渲染的位置。 + +### 其他输入/输出命令 + +​`/popup (文本)`​ — 显示一个阻塞弹窗,支持简单HTML格式,例如:`/popup 我是红色的!`​。 +​`/setinput (文本)`​ — 用提供的文本替换用户输入栏的内容。 +​`/speak voice="名称" (文本)`​ — 使用选定的TTS引擎和语音映射中的角色名称朗读文本,例如 `/speak name="唐老鸭" 嘎嘎!`​。 +​`/buttons labels=["a","b"] (文本)`​ — 显示一个带有指定文本和按钮标签的阻塞弹窗。`labels`​必须是JSON序列化的字符串数组或包含此类数组的变量名。将点击的按钮标签返回到管道,如果取消则返回空字符串。文本支持简单HTML格式。 + +‍ + +#### `/popup`​和`/input`​的参数 + +​`/popup`​和`/input`​支持以下附加命名参数: + +* ​`large=on/off`​ - 增加弹窗的垂直尺寸。默认:`off`​。 +* ​`wide=on/off`​ - 增加弹窗的水平尺寸。默认:`off`​。 +* ​`okButton=字符串`​ - 添加自定义"确定"按钮文本的功能。默认:`Ok`​。 +* ​`rows=数字`​ - (仅适用于`/input`​) 增加输入控件的大小。默认:`1`​。 + +示例: + +``` +/popup large=on wide=on okButton="接受" 请接受我们的条款和条件.... +``` + +‍ + +#### /echo的参数 + +​`/echo`​支持以下附加`severity`​参数值,用于设置显示消息的样式。 + +* ​`warning`​ +* ​`error`​ +* ​`info`​ (默认) +* ​`success`​ + +示例: + +``` +/echo severity=error 发生了非常糟糕的事情。 +``` + +‍ + +## 变量 + +变量用于在脚本中存储和操作数据,可以使用命令或宏。变量可以是以下类型之一: + +* 本地变量 — 保存到当前聊天的元数据中,并且对其唯一。 +* 全局变量 — 保存到settings.json中,并在整个应用程序中存在。 + +‍ + +1. ​`/getvar name`​或`{{getvar::name}}`​ — 获取本地变量的值。 +2. ​`/setvar key=name value`​或`{{setvar::name::value}}`​ — 设置本地变量的值。 +3. ​`/addvar key=name increment`​或`{{addvar::name::increment}}`​ — 将增量添加到本地变量的值。 +4. ​`/incvar name`​或`{{incvar::name}}`​ — 将本地变量的值增加1。 +5. ​`/decvar name`​或`{{decvar::name}}`​ — 将本地变量的值减少1。 +6. ​`/getglobalvar name`​或`{{getglobalvar::name}}`​ — 获取全局变量的值。 +7. ​`/setglobalvar key=name`​或`{{setglobalvar::name::value}}`​ — 设置全局变量的值。 +8. ​`/addglobalvar key=name`​或`{{addglobalvar::name:increment}}`​ — 将增量添加到全局变量的值。 +9. ​`/incglobalvar name`​或`{{incglobalvar::name}}`​ — 将全局变量的值增加1。 +10. ​`/decglobalvar name`​或`{{decglobalvar::name}}`​ — 将全局变量的值减少1。 +11. ​`/flushvar name`​ — 删除本地变量的值。 +12. ​`/flushglobalvar name`​ — 删除全局变量的值。 + +‍ + +* 先前未定义变量的默认值是空字符串,或者如果首次在`/addvar`​、`/incvar`​、`/decvar`​命令中使用,则为零。 +* ​`/addvar`​命令中的增量执行加法或减法(如果增量和变量值都可以转换为数字),否则执行字符串连接。 +* 如果命令参数接受变量名,并且同名的本地和全局变量都存在,则本地变量优先。 +* 所有用于变量操作的斜杠命令都将结果值写入管道,供下一个命令使用。 +* 对于宏,只有"get"、"inc"和"dec"类型的宏返回值,而"add"和"set"则替换为空字符串。 + +‍ + +现在,让我们考虑以下示例: + +``` +/input What do you want to generate? | +/setvar key=SDinput | +/echo Requesting an image of {{getvar::SDinput}} | +/getvar SDinput | +/imagine +``` + +‍ + +1. 用户输入的值保存在名为SDinput的本地变量中。 +2. ​`getvar`​宏用于在`/echo`​命令中显示该值。 +3. ​`getvar`​命令用于检索变量的值并通过管道传递。 +4. 该值传递给`/imagine`​命令(由Image Generation插件提供)作为其输入提示。 + +‍ + +由于变量在脚本执行之间保存且不会刷新,您可以在其他脚本和通过宏中引用该变量,它将解析为与示例脚本执行期间相同的值。为确保值被丢弃,请在脚本中添加`/flushvar`​命令。 + +‍ + +### 数组和对象 + +变量值可以包含JSON序列化的数组或键值对(对象)。 + +‍ + +示例: + +* 数组:["apple","banana","orange"] +* 对象:{"fruits":["apple","banana","orange"]} + +‍ + +以下修改可应用于命令以处理这些变量: + +* ​`/len`​命令获取数组中的项目数量。 +* ​`index=数字/字符串`​命名参数可以添加到`/getvar`​或`/setvar`​及其全局对应项,以通过数组的零基索引或对象的字符串键获取或设置子值。 + + * 如果在不存在的变量上使用数字索引,该变量将被创建为空数组`[]`​。 + * 如果在不存在的变量上使用字符串索引,该变量将被创建为空对象`{}`​。 +* `/addvar`​和`/addglobalvar`​命令支持将新值推送到数组类型的变量。 + +‍ + +## 流程控制 - 条件 + +您可以使用`/if`​命令创建条件表达式,根据定义的规则分支执行。 + +​`/if left=valueA right=valueB rule=comparison else="(false时执行的命令)" "(true时执行的命令)"`​ + +让我们看一下以下示例: + +``` +/input What's your favorite drink? | +/if left={{pipe}} right="black tea" rule=eq else="/echo You shall not pass \| /abort" "/echo Welcome to the club, \{\{user\}\}" +``` + +此脚本根据用户输入与所需值进行评估,并根据输入值显示不同的消息。 + +‍ + +### ​`/if`​的参数 + +1. `left`​是第一个操作数。我们称之为A。 +2. ​`right`​是第二个操作数。我们称之为B。 +3. ​`rule`​是要应用于操作数的操作。 +4. ​`else`​是可选的子命令字符串,如果布尔比较结果为false,则执行这些子命令。 +5. 未命名参数是如果布尔比较结果为true,则执行的子命令。 + +‍ + +操作数值按以下顺序评估: + +1. 数字字面量 +2. 本地变量名 +3. 全局变量名 +4. 字符串字面量 + +‍ + +命名参数的字符串值可以用引号转义,以允许多词字符串。然后丢弃引号。 + +‍ + +### 布尔操作 + +支持的布尔比较规则如下。应用于操作数的操作结果为true或false值。 + +1. ​`eq`​ (等于) => A = B +2. ​`neq`​ (不等于) => A != B +3. ​`lt`​ (小于) => A < B +4. ​`gt`​ (大于) => A > B +5. ​`lte`​ (小于或等于) => A <= B +6. ​`gte`​ (大于或等于) => A >= B +7. ​`not`​ (一元否定) => !A +8. ​`in`​ (包含子字符串) => A包含B,不区分大小写 +9. `nin`​ (不包含子字符串) => A不包含B,不区分大小写 + +‍ + +### 子命令 + +子命令是包含要执行的斜杠命令列表的字符串。 + +1. 要在子命令中使用命令批处理,命令分隔符应该被转义(见下文)。 +2. 由于宏值在进入条件时执行,而不是在执行子命令时执行,因此可以额外转义宏,以延迟其评估到子命令执行时间。 +3. 子命令执行的结果通过管道传递给`/if`​之后的命令。 +4. 遇到`/abort`​命令时,脚本执行中断。 + +‍ + +​`/if`​命令可以用作三元运算符。以下示例将在变量`a`​等于5时将"true"字符串传递给下一个命令,否则传递"false"字符串。 + +``` +/if left=a right=5 rule=eq else="/pass false" "/pass true" | +/echo +``` + +‍ + +## 转义序列 + +### 宏 + +宏的转义方式与之前相同。但是,使用闭包时,您需要比以前少得多地转义宏。可以转义两个开始的大括号,或者同时转义开始和结束的大括号对。 + +``` +/echo \{\{char}} | +/echo \{\{char\}\} +``` + +### 管道 + +闭包中的管道不需要转义(当用作命令分隔符时)。在任何您想使用字面管道字符而不是命令分隔符的地方,您都需要转义它。 + +``` +/echo title="a\|b" c\|d | +/echo title=a\|b c\|d | +``` + +使用解析器标志STRICT_ESCAPING,您不需要在引用值中转义管道。 + +``` +/parser-flag STRICT_ESCAPING | +/echo title="a|b" c\|d | +/echo title=a\|b c\|d | +``` + +### 引号 + +要在引用值内使用字面引号字符,必须转义该字符。 + +``` +/echo title="a \"b\" c" d "e" f +``` + +### 空格 + +要在命名参数的值中使用空格,您必须将值用引号括起来,或者转义空格字符。 + +``` +/echo title="a b" c d | +/echo title=a\ b c d +``` + +### 闭包分隔符 + +如果您想使用用于标记闭包开始或结束的字符组合,您必须使用单个反斜杠转义序列。 + +``` +/echo \{: | +/echo \:} +``` + +## 管道断开器 + +``` +|| +``` + +为了防止前一个命令的输出自动注入为下一个命令的未命名参数,在两个命令之间放置双管道。 + +``` +/echo we don't want to pass this on || +/world +``` + +‍ + +## 闭包 + +``` +{: ... :} +``` + +闭包(块语句、lambda、匿名函数,无论您想叫它什么)是一系列包装在`{:`​和`:}`​之间的命令,只有在代码的那部分被执行时才会被评估。 + +### 子命令 + +闭包使使用子命令变得更加容易,并且不需要转义管道和宏。 + +``` +// 不使用闭包的if | +/if left=1 rule=eq right=1 + else=" + /echo not equal \| + /return 0 + " + /echo equal \| + /return \{\{pipe}} + +// 使用闭包的if | +/if left=1 rule=eq right=1 + else={: + /echo not equal | + /return 0 + :} + {: + /echo equal | + /return {{pipe}} + :} +``` + +### 作用域 + +闭包有自己的作用域并支持作用域变量。作用域变量用`/let`​声明,它们的值用`/var`​设置和获取。获取作用域变量的另一种方法是`{{var::}}`​宏。 + +``` +/let x | +/let y 2 | +/var x 1 | +/var y | +/echo x is {{var::x}} and y is {{pipe}}. +``` + +在闭包内,您可以访问在同一闭包或其祖先之一中声明的所有变量。您无法访问在闭包的后代中声明的变量。 +如果声明的变量与闭包祖先之一中声明的变量同名,则在此闭包及其后代中无法访问祖先变量。 + +``` +/let x this is root x | +/let y this is root y | +/return {: + /echo called from level-1: x is "{{var::x}}" and y is "{{var::y}}" | + /delay 500 | + /let x this is level-1 x | + /echo called from level-1: x is "{{var::x}}" and y is "{{var::y}}" | + /delay 500 | + /return {: + /echo called from level-2: x is "{{var::x}}" and y is "{{var::y}}" | + /let x this is level-2 x | + /echo called from level-2: x is "{{var::x}}" and y is "{{var::y}}" | + /delay 500 + :}() +:}() | +/echo called from root: x is "{{var::x}}" and y is "{{var::y}}" +``` + +### 命名闭包 + +``` +/let x {: ... :} | /:x +``` + +闭包可以分配给变量(仅限作用域变量),以便稍后调用或用作子命令。 + +``` +/let myClosure {: + /echo this is my closure +:} | +/:myClosure +``` + +``` +/let myClosure {: + /echo this is my closure | + /delay 500 +:} | +/times 3 {{var::myClosure}} +``` + +​`/:`​也可以用于执行快速回复,因为它只是`/run`​的简写。 + +``` +/:QrSetName.QrButtonLabel | +/run QrSetName.QrButtonLabel +``` + +### 闭包参数 + +命名闭包可以接受命名参数,就像斜杠命令一样。参数可以有默认值。 + +``` +/let myClosure {: a=1 b= + /echo a is {{var::a}} and b is {{var::b}} +:} | +/:myClosure b=10 +``` + +### 闭包和管道参数 + +父闭包的管道值不会自动注入到子闭包的第一个命令中。 +您仍然可以使用`{{pipe}}`​显式引用父级的管道值,但如果您将闭包内第一个命令的未命名参数留空,则该值不会自动注入。 + +``` +/* 这曾经尝试将模型更改为"foo" + 因为来自循环外部/echo的值"foo" + 被注入到循环内部的/model命令中。 + 现在它将简单地回显当前模型,而不 + 尝试更改它。 +*/ +/echo foo | +/times 2 {: + /model | + /echo | +:} | +``` + +``` +/* 您仍然可以通过显式使用{{pipe}}宏 + 来重现旧行为。 +*/ +/echo foo | +/times 2 {: + /model {{pipe}} | + /echo | +:} | +``` + +### 立即执行闭包 + +``` +{: ... :}() +``` + +闭包可以立即执行,这意味着它们将被替换为其返回值。这在不存在对闭包的显式支持的地方很有用,并且可以缩短一些原本需要大量中间变量的命令。 + +``` +// 不使用闭包的两个字符串长度比较 | +/len foo | +/var lenOfFoo {{pipe}} | +/len bar | +/var lenOfBar {{pipe}} | +/if left={{var::lenOfFoo}} rule=eq right={{var:lenOfBar}} /echo yay! +``` + +``` +// 使用立即执行闭包的相同比较 | +/if left={:/len foo:}() rule=eq right={:/len bar:}() /echo yay! +``` + +除了运行保存在作用域变量中的命名闭包外,`/run`​命令还可用于立即执行闭包。 + +``` +/run {: + /add 1 2 3 4 | +:} | +/echo | +``` + +‍ + +## 注释 + +``` +// ... | /# ... +``` + +注释是脚本代码中的人类可读解释或注解。注释不会中断管道。 + +``` +// 这是一条注释 | +/echo foo | +/# 这也是一条注释 +``` + +### 块注释 + +块注释可用于快速注释掉多个命令。它们不会在管道上终止。 + +``` +/echo foo | +/* +/echo bar | +/echo foobar | +*/ +/echo foo again | +``` + +‍ + +## 流程控制 + +### 循环:`/while`​和`/times`​ + +如果您需要在循环中运行某个命令,直到满足特定条件,请使用`/while`​命令。 + +``` +/while left=valueA right=valueB rule=operation guard=on "commands" +``` + +在循环的每一步,它比较变量A的值与变量B的值,如果条件产生true,则执行引号中包含的任何有效斜杠命令,否则退出循环。此命令不向输出管道写入任何内容。 + +#### + +​`/while`​的参数 + +可用的布尔比较集合、变量处理、字面值和子命令与`/if`​命令相同。 + +可选的`guard`​命名参数(默认为`on`​)用于防止无限循环,将迭代次数限制为100。要禁用并允许无限循环,设置`guard=off`​。 + +此示例将1添加到`i`​的值,直到达到10,然后输出结果值(在本例中为10)。 + +``` +/setvar key=i 0 | +/while left=i right=10 rule=lt "/addvar key=i 1" | +/echo {{getvar::i}} | +/flushvar i +``` + +‍ + +#### `/times`​的参数 + +运行指定次数的子命令。 + +​`/times (重复次数) "(命令)"`​ – 引号中包含的任何有效斜杠命令重复指定次数,例如 `/setvar key=i 1 | /times 5 "/addvar key=i 1"`​ 将1添加到"i"的值5次。 + +* ​`{{timesIndex}}`​被替换为迭代次数(从零开始),例如 `/times 4 "/echo {{timesIndex}}"`​ 回显数字0到4。 +* 循环默认限制为100次迭代,传递`guard=off`​可禁用此限制。 + +‍ + +### 跳出循环和闭包 + +``` +/break | +``` + +​`/break`​命令可用于提前跳出循环(`/while`​或`/times`​)或闭包。`/break`​的未命名参数可用于传递与当前管道不同的值。 +​`/break`​目前在以下命令中实现: + +* ​`/while`​ - 提前退出循环 +* ​`/times`​ - 提前退出循环 +* ​`/run`​(使用闭包或通过变量的闭包)- 提前退出闭包 +* ​`/:`​(使用闭包)- 提前退出闭包 + +``` +/times 10 {: + /echo {{timesIndex}} + /delay 500 | + /if left={{timesIndex}} rule=gt right=3 {: + /break + :} | +:} | +``` + +``` +/let x {: iterations=2 + /if left={{var::iterations}} rule=gt right=10 {: + /break too many iterations! | + :} | + /times {{var::iterations}} {: + /delay 500 | + /echo {{timesIndex}} | + :} | +:} | +/:x iterations=30 | +/echo the final result is: {{pipe}} +``` + +``` +/run {: + /break 1 | + /pass 2 | +:} | +/echo pipe will be one: {{pipe}} | +``` + +``` +/let x {: + /break 1 | + /pass 2 | +:} | +/:x | +/echo pipe will be one: {{pipe}} | +``` + +# + +## 数学运算 + +* 以下所有操作都接受一系列数字或变量名,并将结果输出到管道。 +* 无效操作(如除以零)以及导致NaN值或无穷大的操作返回零。 +* 乘法、加法、最小值和最大值接受无限数量的由空格分隔的参数。 +* 减法、除法、幂运算和模运算接受由空格分隔的两个参数。 +* 正弦、余弦、自然对数、平方根、绝对值和舍入接受一个参数。 + +操作列表: + +1. ​`/add (a b c d)`​ – 执行一组值的加法,例如 `/add 10 i 30 j`​ +2. ​`/mul (a b c d)`​ – 执行一组值的乘法,例如 `/mul 10 i 30 j`​ +3. ​`/max (a b c d)`​ – 返回一组值中的最大值,例如 `/max 1 0 4 k`​ +4. ​`/min (a b c d)`​ – 返回一组值中的最小值,例如 `/min 5 4 i 2`​ +5. ​`/sub (a b)`​ – 执行两个值的减法,例如 `/sub i 5`​ +6. ​`/div (a b)`​ – 执行两个值的除法,例如 `/div 10 i`​ +7. ​`/mod (a b)`​ – 执行两个值的模运算,例如 `/mod i 2`​ +8. ​`/pow (a b)`​ – 执行两个值的幂运算,例如 `/pow i 2`​ +9. ​`/sin (a)`​ – 执行一个值的正弦运算,例如 `/sin i`​ +10. ​`/cos (a)`​ – 执行一个值的余弦运算,例如 `/cos i`​ +11. ​`/log (a)`​ – 执行一个值的自然对数运算,例如 `/log i`​ +12. ​`/abs (a)`​ – 执行一个值的绝对值运算,例如 `/abs -10`​ +13. ​`/sqrt (a)`​– 执行一个值的平方根运算,例如 `/sqrt 9`​ +14. ​`/round (a)`​ – 执行一个值的四舍五入到最接近整数的运算,例如 `/round 3.14`​ +15. `/rand (round=round|ceil|floor from=number=0 to=number=1)`​ – 返回一个介于from和to之间的随机数,例如 `/rand`​ 或 `/rand 10`​ 或 `/rand from=5 to=10`​。范围是包含的。返回的值将包含小数部分。使用`round`​命名参数获取整数值,例如 `/rand round=ceil`​ 向上舍入,`round=floor`​ 向下舍入,`round=round`​ 舍入到最接近的值。 + +‍ + +### 示例1:获取半径为50的圆的面积。 + +``` +/setglobalvar key=PI 3.1415 | +/setvar key=r 50 | +/mul r r PI | +/round | +/echo Circle area: {{pipe}} +``` + +### 示例2:计算5的阶乘。 + +``` +/setvar key=input 5 | +/setvar key=i 1 | +/setvar key=product 1 | +/while left=i right=input rule=lte "/mul product i \| /setvar key=product \| /addvar key=i 1" | +/getvar product | +/echo Factorial of {{getvar::input}}: {{pipe}} | +/flushvar input | +/flushvar i | +/flushvar product +``` + +‍ + +## 使用LLM + +脚本可以使用以下命令向您当前连接的LLM API发出请求: + +* ​`/gen (提示)`​ — 使用为所选角色提供的提示生成文本,并包含聊天消息。 +* ​`/genraw (提示)`​ — 仅使用提供的提示生成文本,忽略当前角色和聊天。 +* `/trigger`​ — 触发正常生成(相当于点击"发送"按钮)。如果在群聊中,您可以选择提供基于1的群组成员索引或角色名称让他们回复,否则根据群组设置触发群组回合。 + +### `/gen`​和`/genraw`​的参数 + +``` +/genraw lock=on/off stop=[] instruct=on/off (Prompt) +``` + +‍ + +* ​`lock`​ — 可以是`on`​或`off`​。指定生成过程中是否应阻止用户输入。默认:`off`​。 +* ​`stop`​ — JSON序列化的字符串数组。仅为此生成添加自定义停止字符串(如果API支持)。默认:无。 +* ​`instruct`​(仅`/genraw`​)— 可以是`on`​或`off`​。允许在输入提示上使用指令格式(如果启用了指令模式且API支持)。设置为`off`​强制使用纯提示。默认:`on`​。 +* ​`as`​(用于文本完成API)— 可以是`system`​(默认)或`char`​。定义最后一行提示将如何格式化。`char`​将使用角色名称,`system`​将使用无名称或中性名称。 + +‍ + +生成的文本然后通过管道传递给下一个命令,可以保存到变量或使用I/O功能显示: + +``` +/genraw Write a funny message from Cthulhu about taking over the world. Use emojis. | +/popup

Cthulhu says:

{{pipe}}
+``` + +或者将生成的消息作为角色的回复插入: + +``` +/genraw You have been memory wiped, your name is now Lisa and you're tearing me apart. You're tearing me apart Lisa! | +/sendas name={{char}} {{pipe}} +``` + +‍ + +## 提示注入 + +脚本可以添加自定义LLM提示注入,本质上相当于无限的作者注释。 + +* ​`/inject (文本)`​ — 将任何文本插入到当前聊天的正常LLM提示中,并需要一个唯一标识符。保存到聊天元数据。 +* ​`/listinjects`​ — 在系统消息中显示脚本为当前聊天添加的所有提示注入列表。 +* ​`/flushinjects`​ — 删除脚本为当前聊天添加的所有提示注入。 +* ​`/note (文本)`​ — 设置当前聊天的作者注释值。保存到聊天元数据。 +* ​`/interval`​ — 设置当前聊天的作者注释插入间隔。 +* ​`/depth`​ — 设置聊天内位置的作者注释插入深度。 +* `/position`​ — 设置当前聊天的作者注释位置。 + +‍ + +### `/inject`​的参数 + +``` +/inject id=IdGoesHere position=chat depth=4 My prompt injection +``` + +​`id`​ — 标识符字符串或对变量的引用。使用相同ID的连续`/inject`​调用将覆盖先前的文本注入。必需参数。 +​`position`​ — 设置注入的位置。默认:`after`​。可能的值: +​`after`​:在主提示之后。 +​`before`​:在主提示之前。 +​`chat`​:在聊天中。 +​`depth`​ — 设置聊天内位置的注入深度。0表示在最后一条消息之后插入,1表示在最后一条消息之前,依此类推。默认:4。 +未命名参数是要注入的文本。空字符串将取消设置提供的标识符的先前值。 + +‍ + +## 访问聊天消息 + +### 读取消息 + +您可以使用`/messages`​命令访问当前选定聊天中的消息。 + +``` +/messages names=on/off start-finish +``` + +* `names`​参数用于指定是否要包含角色名称,默认:`on`​。 + +* 在未命名参数中,它接受消息索引或start-finish格式的范围。范围是包含的! +* 如果范围不可满足,即无效索引或请求的消息数量超过存在的消息数量,则返回空字符串。 +* 从提示中隐藏的消息(由幽灵图标表示)从输出中排除。 +* 如果您想知道最新消息的索引,请使用`{{lastMessageId}}`​宏,而`{{lastMessage}}`​将获取消息本身。 + +要计算范围的起始索引,例如,当您需要获取最后N条消息时,请使用变量减法。此示例将获取聊天中的最后3条消息: + +``` +/setvar key=start {{lastMessageId}} | +/addvar key=start -2 | +/messages names=off {{getvar::start}}-{{lastMessageId}} | +/setinput +``` + +‍ + +### 发送消息 + +脚本可以作为用户、角色、人物、中立叙述者发送消息,或添加评论。 + +1. ​`/send (文本)`​ — 作为当前选定的人物添加消息。 +2. ​`/sendas name=charname (文本)`​ — 作为任何角色添加消息,通过其名称匹配。`name`​参数是必需的。使用`{{char}}`​宏作为当前角色发送。 +3. ​`/sys (文本)`​ — 添加来自中立叙述者的消息,不属于用户或角色。显示的名称纯粹是装饰性的,可以使用`/sysname`​命令自定义。 +4. ​`/comment (文本)`​ — 添加在聊天中显示但在提示中不可见的隐藏评论。 +5. ​`/addswipe (文本)`​ — 为最后一条角色消息添加滑动。不能为用户或隐藏消息添加滑动。 +6. ​`/hide (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,从提示中隐藏一条或多条消息。 +7. ​`/unhide (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,将一条或多条消息返回到提示中。 + +`/send`​、`/sendas`​、`/sys`​和`/comment`​命令可选地接受一个名为`at`​的命名参数,其值为基于零的数字(或包含此类值的变量名),指定消息插入的确切位置。默认情况下,新消息插入在聊天日志的末尾。 + +这将在对话历史的开头插入一条用户消息: + +``` +/send at=0 Hi, I use Linux. +``` + +‍ + +### 删除消息 + +这些命令具有潜在的破坏性,没有"撤销"功能。如果您不小心删除了重要内容,请检查/backups/文件夹。 + +1. ​`/cut (消息ID或范围)`​ — 根据提供的消息索引或start-finish格式的包含范围,从聊天中剪切一条或多条消息。 +2. ​`/del (数字)`​ — 从聊天中删除最后N条消息。 +3. ​`/delswipe (基于1的滑动ID)`​ — 根据提供的基于1的滑动ID,从最后一条角色消息中删除滑动。 +4. ​`/delname (角色名称)`​ — 删除当前聊天中属于指定名称角色的所有消息。 +5. `/delchat`​ — 删除当前聊天。 + +‍ + +## 世界信息命令 + +世界信息(也称为Lorebook)是一种高度实用的工具,用于动态将数据插入提示。有关更详细的解释,请参阅专门的页面:==世界信息==。 + +1. ​`/getchatbook`​ – 获取聊天绑定的世界信息文件名称,如果未绑定则创建一个新的,并通过管道传递。 +2. ​`/findentry file=bookName field=fieldName [text]`​ – 使用字段值与提供的文本的模糊匹配,从指定文件(或指向文件名的变量)中查找记录的UID(默认字段:key),并通过管道传递UID,例如 `/findentry file=chatLore field=key Shadowfang`​。 +3. ​`/getentryfield file=bookName field=field [UID]`​ – 获取指定世界信息文件(或指向文件名的变量)中UID记录的字段值(默认字段:content),并通过管道传递值,例如 `/getentryfield file=chatLore field=content 123`​。 +4. ​`/setentryfield file=bookName uid=UID field=field [text]`​ – 设置指定世界信息文件(或指向文件名的变量)中UID(或指向UID的变量)记录的字段值(默认字段:content)。要为key字段设置多个值,请使用逗号分隔的列表作为文本值,例如 `/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon`​。 +5. `/createentry file=bookName key=keyValue [content text]`​ – 在指定文件(或指向文件名的变量)中创建一个新记录,带有key和content(这两个参数都是可选的),并通过管道传递UID,例如 `/createentry file=chatLore key=Shadowfang The sword of the king`​。 + +### 有效条目字段 + +|字段|UI元素|值类型| +| ------| ---------------| :-----------: | +|​`content`​|内容|字符串| +|​`comment`​|标题/备忘录|字符串| +|​`key`​|主关键词|字符串列表| +|​`keysecondary`​|可选过滤器|字符串列表| +|​`constant`​|常量状态|布尔值(1/0)| +|​`disable`​|禁用状态|布尔值(1/0)| +|​`order`​|顺序|数字| +|​`selectiveLogic`​|逻辑|(见下文)| +|​`excludeRecursion`​|不可递归|布尔值(1/0)| +|​`probability`​|触发%|数字(0-100)| +|​`depth`​|深度|数字(0-999)| +|​`position`​|位置|(见下文)| +|​`role`​|深度角色|(见下文)| +|​`scanDepth`​|扫描深度|数字(0-100)| +|​`caseSensitive`​|caseSensitive|布尔值(1/0)| +|​`matchWholeWords`​|匹配整词|布尔值(1/0)| + +‍ + +#### 逻辑值 + +* 0 = AND ANY +* 1 = NOT ALL +* 2 = NOT ANY +* 3 = AND ALL + +#### 位置值 + +* 0 = 主提示之前 +* 1 = 主提示之后 +* 2 = 作者注释顶部 +* 3 = 作者注释底部 +* 4 = 聊天中的深度 +* 5 = 示例消息顶部 +* 6 = 示例消息底部 + +#### 角色值(仅限位置 = 4) + +* 0 = 系统 +* 1 = 用户 +* 2 = 助手 + +‍ + +### 示例1:通过关键字从聊天知识库中读取内容 + +``` +/getchatbook | /setvar key=chatLore | +/findentry file={{getvar::chatLore}} field=key Shadowfang | +/getentryfield file={{getvar::chatLore}} field=key | +/echo +``` + +### 示例2:创建带有关键字和内容的聊天知识库条目 + +``` +/getchatbook | /setvar key=chatLore | +/createentry file={{getvar::chatLore}} key="Milla" Milla Basset is a friend of Lilac and Carol. She is a hush basset puppy who possesses the power of alchemy. | +/echo +``` + +### 示例3:用聊天中的新信息扩展现有知识库条目 + +``` +/getchatbook | /setvar key=chatLore | +/findentry file={{getvar::chatLore}} field=key Milla | +/setvar key=millaUid | +/getentryfield file={{getvar::chatLore}} field=content | +/setvar key=millaContent | +/gen lock=on Tell me more about Milla Basset based on the provided conversation history. Incorporate existing information into your reply: {{getvar::millaContent}} | +/setvar key=millaContent | +/echo New content: {{pipe}} | +/setentryfield file={{getvar::chatLore}} uid=millaUid field=content {{getvar::millaContent}} +``` + +‍ + +## 文本操作 + +有各种有用的文本操作实用命令,可用于各种脚本场景。 + +1. ​`/trimtokens`​ — 将输入修剪为从开始或从结尾指定数量的文本标记,并将结果输出到管道。 +2. ​`/trimstart`​ — 将输入修剪到第一个完整句子的开始,并将结果输出到管道。 +3. ​`/trimend`​ — 将输入修剪到最后一个完整句子的结尾,并将结果输出到管道。 +4. ​`/fuzzy`​ — 对输入文本执行与字符串列表的模糊匹配,将最佳字符串匹配输出到管道。 +5. `/regex name=scriptName [text]`​ — 为指定文本执行正则表达式扩展中的正则表达式脚本。脚本必须启用。 + +### `/trimtokens`​的参数 + +``` +/trimtokens limit=number direction=start/end (input) +``` + +1. ​`direction`​设置修剪的方向,可以是`start`​或`end`​。默认:`end`​。 +2. ​`limit`​设置输出中保留的标记数量。也可以指定包含数字的变量名。必需参数。 +3. 未命名参数是要修剪的输入文本。 + +### `/fuzzy`​的参数 + +``` +/fuzzy list=["candidate1","candidate2"] (input) +``` + +1. ​`list`​是包含候选项的JSON序列化字符串数组。也可以指定包含列表的变量名。必需参数。 +2. 未命名参数是要匹配的输入文本。输出是与输入最接近匹配的候选项之一。 + +‍ + +## 自动完成 + +* 自动完成在聊天输入和大型快速回复编辑器中都已启用。 +* 自动完成在您的输入中的任何位置都有效。即使有多个管道命令和嵌套闭包。 +* 自动完成支持三种查找匹配命令的方式(用户设置 -> STscript匹配)。 + +‍ + +1. **以...开头** "旧"方式。只有以输入的值精确开头的命令才会显示。 +2. **包含** 所有包含输入值的命令都会显示。例如:当输入`/delete`​时,命令`/qr-delete`​和`/qr-set-delete`​将显示在自动完成列表中(`/qr-delete`​,`/qr-set-delete`​)。 +3. 模糊 所有可以与输入值模糊匹配的命令都会显示。例如:当输入`/seas`​时,命令`/sendas`​将显示在自动完成列表中(`/sendas`​)。 + +‍ + +* 命令参数也受自动完成支持。列表将自动显示必需参数。对于可选参数,按Ctrl+Space打开可用选项列表。 +* 当输入`/:`​执行闭包或QR时,自动完成将显示作用域变量和QR的列表。 + 自动完成对宏(在斜杠命令中)有有限支持。输入`{{`​获取可用宏的列表。 +* 使用上下箭头键从自动完成选项列表中选择一个选项。 +* 按Enter或Tab或点击一个选项将该选项放置在光标处。 +* 按Escape关闭自动完成列表。 +* 按Ctrl+Space打开自动完成列表或切换所选选项的详细信息。 + +‍ + +## 解析器标志 + +``` +/parser-flag +``` + +解析器接受标志来修改其行为。这些标志可以在脚本中的任何点切换开关,所有后续输入将相应地进行评估。 +您可以在用户设置中设置默认标志。 + +‍ + +### 严格转义 + +``` +/parser-flag STRICT_ESCAPING on | +``` + +启用`STRICT_ESCAPING`​后的变化如下。 + +#### 管道 + +引用值中的管道不需要转义。 + +``` +/echo title="a|b" c\|d +``` + +#### 反斜杠 + +符号前的反斜杠可以被转义,以提供后面跟着功能符号的字面反斜杠。 + +``` +// 这将回显"foo \",然后回显"bar" | +/echo foo \\| +/echo bar + +/echo \\| +/echo \\\| +``` + +### 替换变量宏 + +``` +/parser-flag REPLACE_GETVAR on | +``` + +此标志有助于避免当变量值包含可能被解释为宏的文本时发生双重替换。`{{var::}}`​宏最后被替换,并且在结果文本/变量值上不会发生进一步的替换。 + +将所有`{{getvar::}}`​和`{{getglobalvar::}}`​宏替换为`{{var::}}`​。在幕后,解析器将在带有替换宏的命令之前插入一系列命令执行器: + +* 调用`/let`​保存当前`{{pipe}}`​到作用域变量 +* 调用`/getvar`​或`/getglobalvar`​获取宏中使用的变量 +* 调用`/let`​将检索到的变量保存到作用域变量 +* 调用`/return`​并带有保存的`{{pipe}}`​值,以恢复下一个命令的正确管道值 + +``` +// 以下将回显最后一条消息的id/编号 | +/setvar key=x \{\{lastMessageId}} | +/echo {{getvar::x}} +``` + +``` +// 这将回显字面文本{{lastMessageId}} | +/parser-flag REPLACE_GETVAR | +/setvar key=x \{\{lastMessageId}} | +/echo {{getvar::x}} +``` + +‍ + +## 快速回复:脚本库和自动执行 + +快速回复是一个内置的SillyTavern扩展,提供了一种简单的方式来存储和执行您的脚本。 + +### 配置快速回复 + +要开始使用,请打开扩展面板(堆叠块图标),并展开快速回复菜单。 + +**快速回复默认是禁用的,您需要先启用它们。** 然后您将看到一个栏出现在聊天输入栏上方。 + +您可以设置显示的按钮文本标签(我们建议使用表情符号以简洁)和点击按钮时将执行的脚本。 + +插槽数量由 **"插槽数量"** 设置控制(最大=100),根据您的需要调整它,完成后点击"应用"。 + + **"自动注入用户输入"** 建议在使用STscript时禁用,否则可能会干扰您的输入,请在脚本中使用`{{input}}`​宏获取输入栏的当前值。 + +快速回复预设允许有多组预定义的快速回复,可以手动切换或使用`/qrset(预设名称)`​命令切换。切换到不同的预设前,不要忘记点击"更新"以将您的更改写入当前使用的预设! + +‍ + +### 手动执行 + +现在您可以将第一个脚本添加到库中。选择任何空闲插槽(或创建一个),在左框中输入"点击我"设置标签,然后将以下内容粘贴到右框中: + +``` +/addvar key=clicks 1 | +/if left=clicks right=5 rule=eq else="/echo Keep going..." "/echo You did it! \| /flushvar clicks" +``` + +然后点击出现在聊天栏上方的按钮5次。每次点击将变量`clicks`​的值增加1,当值等于5时显示不同的消息并重置变量。 + +‍ + +### 自动执行 + +通过点击创建命令的`⋮`​按钮打开模态菜单。 + +在此菜单中,您可以执行以下操作: + +* 在方便的全屏编辑器中编辑脚本 +* 从聊天栏隐藏按钮,使其只能通过自动执行访问。 +* 在以下一个或多个条件下启用自动执行: + + * 应用启动 + * 向聊天发送用户消息 + * 在聊天中接收AI消息 + * 打开角色或群组聊天 + * 触发群组成员回复 + * 使用相同的自动化ID激活世界信息条目 + +* 为快速回复提供自定义工具提示(悬停在UI中的快速回复上显示的文本) +* 执行脚本进行测试 + +‍ + +只有在启用快速回复扩展时,命令才会自动执行。 + +例如,您可以通过添加以下脚本并设置为在用户消息上自动执行,在发送五条用户消息后显示一条消息。 + +``` +/addvar key=usercounter 1 | +/echo You've sent {{pipe}} messages. | +/if left=usercounter right=5 rule=gte "/echo Game over! \| /flushvar usercounter" +``` + +### 调试器 + +在扩展的快速回复编辑器中存在一个基本调试器。在脚本中的任何地方设置断点,使用`/breakpoint |`​。从QR编辑器执行脚本时,执行将在该点中断,允许您检查当前可用的变量、管道、命令参数等,并逐步执行剩余代码。 + +``` +/let x {: n=1 + /echo n is {{var::n}} | + /mul n n | +:} | +/breakpoint | +/:x n=3 | +/echo result is {{pipe}} | +``` + +### 调用过程 + +​`/run`​命令可以通过其标签调用在快速回复中定义的脚本,基本上提供了定义过程并从中返回结果的能力。这允许有可重用的脚本块,其他脚本可以引用。过程管道中的最后一个结果将传递给其后的下一个命令。 + +``` +/run ScriptLabel +``` + +让我们创建两个快速回复: + +--- + +标签: + +​`GetRandom`​ + +命令: + +``` +/pass {{roll:d100}} +``` + +--- + +标签: + +​`GetMessage`​ + +命令: + +``` +/run GetRandom | /echo Your lucky number is: {{pipe}} +``` + +点击GetMessage按钮将调用GetRandom过程,该过程将解析{{roll}}宏并将数字传递给调用者,显示给用户。 + +* 过程不接受命名或未命名参数,但可以引用与调用者相同的变量。 +* 调用过程时避免递归,因为如果处理不当,可能会产生"调用栈超出"错误。 + +#### 从不同快速回复预设调用过程 + +您可以使用`a.b`​语法从不同的快速回复预设调用过程,其中`a`​ = QR预设名称,`b`​ = QR标签名称 + +``` +/run QRpreset1.QRlabel1 +``` + +默认情况下,系统将首先查找标签为a.b的快速回复,因此如果您的标签之一字面上是"QRpreset1.QRlabel1",它将尝试运行该标签。如果找不到这样的标签,它将搜索名为"QRpreset1"的QR预设,其中有一个标记为"QRlabel1"的QR。 + +‍ + +### 快速回复管理命令 + +#### 创建快速回复 + +​`/qr-create (参数, [消息])`​ – 创建一个新的快速回复,例如:`/qr-create set=MyPreset label=MyButton /echo 123`​ + +参数: + +* ​`label`​ - 字符串 - 按钮上的文本,例如,`label=MyButton`​ +* ​`set`​ - 字符串 - QR集的名称,例如,`set=PresetName1`​ +* ​`hidden`​ - 布尔值 - 按钮是否应该隐藏,例如,`hidden=true`​ +* ​`startup`​ - 布尔值 - 应用启动时自动执行,例如,`startup=true`​ +* ​`user`​ - 布尔值 - 用户消息时自动执行,例如,`user=true`​ +* ​`bot`​ - 布尔值 - AI消息时自动执行,例如,`bot=true`​ +* ​`load`​ - 布尔值 - 聊天加载时自动执行,例如,`load=true`​ +* ​`title`​ - 布尔值 - 在按钮上显示的标题/工具提示,例如,`title="My Fancy Button"`​ + +‍ + +#### 删除快速回复 + +* ​`/qr-delete (set=string [label])`​ – 删除快速回复 + +#### 更新快速回复 + +* ​`/qr-update (参数, [消息])`​ – 更新快速回复,例如:`/qr-update set=MyPreset label=MyButton newlabel=MyRenamedButton /echo 123`​ + +‍ + +参数: + +* ​`newlabel `​- 字符串 - 按钮的新文本,例如 `newlabel=MyRenamedButton`​ +* ​`label`​ - 字符串 - 按钮上的文本,例如,`label=MyButton`​ +* ​`set`​ - 字符串 - QR集的名称,例如,`set=PresetName1`​ +* ​`hidden`​ - 布尔值 - 按钮是否应该隐藏,例如,`hidden=true`​ +* ​`startup`​ - 布尔值 - 应用启动时自动执行,例如,`startup=true`​ +* ​`user`​ - 布尔值 - 用户消息时自动执行,例如,`user=true`​ +* ​`bot`​ - 布尔值 - AI消息时自动执行,例如,`bot=true`​ +* ​`load`​ - 布尔值 - 聊天加载时自动执行,例如,`load=true`​ +* ​`title`​ - 布尔值 - 在按钮上显示的标题/工具提示,例如,`title="My Fancy Button"`​ + +* `qr-get`​ - 检索快速回复的所有属性,例如:`/qr-get set=myQrSet id=42`​ + +‍ + +#### 创建或更新QR预设 + +* ​`/qr-presetupdate (参数 [标签])`​ 或 `/qr-presetadd (参数 [标签])`​ + +参数: + +* ​`enabled`​ - 布尔值 - 启用或禁用预设 +* ​`nosend`​ - 布尔值 - 禁用发送/插入用户输入(对斜杠命令无效) +* ​`before`​ - 布尔值 - 在用户输入前放置QR +* ​`slots`​ - 整数 - 插槽数量 +* ​`inject`​ - 布尔值 - 自动注入用户输入(如果禁用,使用`{{input}}`​) + +创建一个新预设(覆盖现有预设),例如:`/qr-presetadd slots=3 MyNewPreset`​ + +#### 添加QR上下文菜单 + +* `/qr-contextadd (set=string label=string chain=bool [preset name])`​ – 向QR添加上下文菜单预设,例如:`/qr-contextadd set=MyPreset label=MyButton chain=true MyOtherPreset`​ + +#### 删除所有上下文菜单 + +* `/qr-contextclear (set=string [label])`​ – 从QR中删除所有上下文菜单预设,例如:`/qr-contextclear set=MyPreset MyButton`​ + +#### 删除一个上下文菜单 + +* ​`/qr-contextdel (set=string label=string [preset name])`​ – 从QR中删除上下文菜单预设,例如:`/qr-contextdel set=MyPreset label=MyButton MyOtherPreset`​ + +‍ + +### 快速回复值转义 + +​`|{}`​可以在QR消息/命令中用反斜杠转义。 + +例如,使用`/qr-create label=MyButton /getvar myvar | /echo {{pipe}}`​创建一个调用`/getvar myvar | /echo {{pipe}}`​的QR。 + +‍ + +## 扩展命令 + +SillyTavern扩展(内置、可下载和第三方)可以添加自己的斜杠命令。以下只是官方扩展中功能的示例。列表可能不完整,请务必查看/help slash获取最完整的可用命令列表。 + +1. ​`/websearch`​ (查询) — 在线搜索网页片段,查找指定查询并将结果返回到管道。由Web Search扩展提供。 +2. ​`/imagine`​ (提示) — 使用提供的提示生成图像。由Image Generation扩展提供。 +3. ​`/emote`​ (精灵) — 通过模糊匹配其名称为活动角色设置精灵。由Character Expressions扩展提供。 +4. ​`/costume`​ (子文件夹) — 为活动角色设置精灵集覆盖。由Character Expressions扩展提供。 +5. ​`/music`​ (名称) — 强制更改播放的背景音乐文件,通过其名称。由Dynamic Audio扩展提供。 +6. ​`/ambient`​ (名称) — 强制更改播放的环境声音文件,通过其名称。由Dynamic Audio扩展提供。 +7. ​`/roll`​ (骰子公式) — 向聊天添加带有骰子掷出结果的隐藏消息。由D&D Dice扩展提供。 + +‍ + +## UI交互 + +脚本还可以与SillyTavern的UI交互:浏览聊天或更改样式参数。 + +### 角色导航 + +* ​`/random`​ — 打开与随机角色的聊天。 +* `/go (名称)`​ — 打开与指定名称角色的聊天。首先搜索精确名称匹配,然后按前缀,然后按子字符串。 + +### UI样式 + +1. ​`/bubble`​ — 将消息样式设置为"气泡聊天"样式。 +2. `/flat`​ — 将消息样式设置为"平面聊天"样式。 +3. `/single`​ — 将消息样式设置为"单一文档"样式。 +4. `/movingui (名称)`​ — 通过名称激活MovingUI预设。 +5. `/resetui`​ — 将MovingUI面板状态重置为其原始位置。 +6. `/panels`​ — 切换UI面板可见性:顶部栏、左侧和右侧抽屉。 +7. `/bg (名称)`​ — 使用模糊名称匹配查找并设置背景。尊重聊天背景锁定状态。 +8. `/lockbg`​ — 锁定当前聊天的背景图像。 +9. `/unlockbg`​ — 解锁当前聊天的背景图像。 + +‍ + +## 更多示例 + +### 生成聊天摘要(由@IkariDevGIT提供) + +``` +/setglobalvar key=summaryPrompt Summarize the most important facts and events that have happened in the chat given to you in the Input header. Limit the summary to 100 words or less. Your response should include nothing but the summary. | +/setvar key=tmp | +/messages 0-{{lastMessageId}} | +/trimtokens limit=3000 direction=end | +/setvar key=s1 | +/echo Generating, please wait... | +/genraw lock=on instruct=off {{instructInput}}{{newline}}{{getglobalvar::summaryPrompt}}{{newline}}{{newline}}{{instructInput}}{{newline}}{{getvar::s1}}{{newline}}{{newline}}{{instructOutput}}{{newline}}The chat summary:{{newline}} | +/setvar key=tmp | +/echo Done! | +/setinput {{getvar::tmp}} | +/flushvar tmp | +/flushvar s1 +``` + +### 按钮弹窗使用 + +``` +/setglobalvar key=genders ["boy", "girl", "other"] | +/buttons labels=genders Who are you? | +/echo You picked: {{pipe}} +``` + +### 获取第N个斐波那契数(使用比内公式) + +> 提示:将fib_no的值设置为所需的数字 + +``` +/setvar key=fib_no 5 | +/pow 5 0.5 | /setglobalvar key=SQRT5 | +/setglobalvar key=PHI 1.618033 | +/pow PHI fib_no | /div {{pipe}} SQRT5 | +/round | +/echo {{getvar::fib_no}}th Fibonacci's number is: {{pipe}} +``` + +### 递归阶乘(使用闭包) + +``` +/let fact {: n= + /if left={{var::n}} rule=gt right=1 + else={: + /return 1 + :} + {: + /sub {{var::n}} 1 | + /:fact n={{pipe}} | + /mul {{var::n}} {{pipe}} + :} +:} | + +/input Calculate factorial of: | +/let n {{pipe}} | +/:fact n={{var::n}} | +/echo factorial of {{var::n}} is {{pipe}} ``` \ No newline at end of file diff --git a/index.js b/index.js index 9f0ebaf..c86c989 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ -// ═══════════════════════════════════════════════════════════════════════════ -// 导入 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Imports +// =========================================================================== import { extension_settings, getContext } from "../../../extensions.js"; import { saveSettingsDebounced, eventSource, event_types, getRequestHeaders } from "../../../../script.js"; @@ -35,9 +35,9 @@ import { initNovelDraw, cleanupNovelDraw } from "./modules/novel-draw/novel-draw import "./modules/story-summary/story-summary.js"; import "./modules/story-outline/story-outline.js"; -// ═══════════════════════════════════════════════════════════════════════════ -// 常量与默认设置 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Constants and Default Settings +// =========================================================================== const MODULE_NAME = "xiaobaix-memory"; @@ -67,9 +67,9 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || { const settings = extension_settings[EXT_ID]; if (settings.dynamicPrompt && !settings.fourthWall) settings.fourthWall = settings.dynamicPrompt; -// ═══════════════════════════════════════════════════════════════════════════ -// 废弃数据清理 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Deprecated Data Cleanup +// =========================================================================== const DEPRECATED_KEYS = [ 'characterUpdater', @@ -87,19 +87,19 @@ function cleanupDeprecatedData() { if (key in s) { delete s[key]; cleaned = true; - console.log(`[LittleWhiteBox] 清理废弃数据: ${key}`); + console.log(`[LittleWhiteBox] Cleaned deprecated data: ${key}`); } } if (cleaned) { saveSettingsDebounced(); - console.log('[LittleWhiteBox] 废弃数据清理完成'); + console.log('[LittleWhiteBox] Deprecated data cleanup complete'); } } -// ═══════════════════════════════════════════════════════════════════════════ -// 状态变量 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// State Variables +// =========================================================================== let isXiaobaixEnabled = settings.enabled; let moduleCleanupFunctions = new Map(); @@ -117,9 +117,9 @@ window.testRemoveUpdateUI = () => { removeAllUpdateNotices(); }; -// ═══════════════════════════════════════════════════════════════════════════ -// 更新检查 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Update Check +// =========================================================================== async function checkLittleWhiteBoxUpdate() { try { @@ -148,16 +148,16 @@ async function updateLittleWhiteBoxExtension() { }); if (!response.ok) { const text = await response.text(); - toastr.error(text || response.statusText, '小白X更新失败', { timeOut: 5000 }); + toastr.error(text || response.statusText, 'LittleWhiteBox update failed', { timeOut: 5000 }); return false; } const data = await response.json(); - const message = data.isUpToDate ? '小白X已是最新版本' : `小白X已更新`; + const message = data.isUpToDate ? 'LittleWhiteBox is up to date' : `LittleWhiteBox updated`; const title = data.isUpToDate ? '' : '请刷新页面以应用更新'; toastr.success(message, title); return true; } catch (error) { - toastr.error('更新过程中发生错误', '小白X更新失败'); + toastr.error('Error during update', 'LittleWhiteBox update failed'); return false; } } @@ -213,7 +213,7 @@ function addUpdateDownloadButton() { const updateButton = document.createElement('div'); updateButton.id = 'littlewhitebox-update-extension'; updateButton.className = 'menu_button fa-solid fa-cloud-arrow-down interactable has-update'; - updateButton.title = '下载并安装小白x的更新'; + updateButton.title = '下载并安装小白X的更新'; updateButton.tabIndex = 0; try { totalSwitchDivider.style.display = 'flex'; @@ -246,9 +246,9 @@ async function performExtensionUpdateCheck() { } catch (error) {} } -// ═══════════════════════════════════════════════════════════════════════════ -// 模块清理注册 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Module Cleanup Registration +// =========================================================================== function registerModuleCleanup(moduleName, cleanupFunction) { moduleCleanupFunctions.set(moduleName, cleanupFunction); @@ -295,9 +295,9 @@ function cleanupAllResources() { removeSkeletonStyles(); } -// ═══════════════════════════════════════════════════════════════════════════ -// 工具函数 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Utility Functions +// =========================================================================== async function waitForElement(selector, root = document, timeout = 10000) { const start = Date.now(); @@ -309,9 +309,9 @@ async function waitForElement(selector, root = document, timeout = 10000) { return null; } -// ═══════════════════════════════════════════════════════════════════════════ -// 设置控件禁用/启用 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Settings Controls Toggle +// =========================================================================== function toggleSettingsControls(enabled) { const controls = [ @@ -360,11 +360,11 @@ function setActiveClass(enable) { document.body.classList.toggle('xiaobaix-active', !!enable); } -// ═══════════════════════════════════════════════════════════════════════════ -// 功能总开关切换 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Toggle All Features +// =========================================================================== -function toggleAllFeatures(enabled) { +async function toggleAllFeatures(enabled) { if (enabled) { if (settings.renderEnabled !== false) { ensureHideCodeStyle(true); @@ -376,8 +376,10 @@ function toggleAllFeatures(enabled) { initRenderer(); try { initVarCommands(); } catch (e) {} try { initVareventEditor(); } catch (e) {} + if (extension_settings[EXT_ID].tasks?.enabled) { + await initTasks(); + } const moduleInits = [ - { condition: extension_settings[EXT_ID].tasks?.enabled, init: initTasks }, { condition: extension_settings[EXT_ID].scriptAssistant?.enabled, init: initScriptAssistant }, { condition: extension_settings[EXT_ID].immersive?.enabled, init: initImmersiveMode }, { condition: extension_settings[EXT_ID].templateEditor?.enabled, init: initTemplateEditor }, @@ -441,9 +443,9 @@ function toggleAllFeatures(enabled) { } } -// ═══════════════════════════════════════════════════════════════════════════ -// 设置面板初始化 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Settings Panel Setup +// =========================================================================== async function setupSettings() { try { @@ -455,20 +457,20 @@ async function setupSettings() { setupDebugButtonInSettings(); - $("#xiaobaix_enabled").prop("checked", settings.enabled).on("change", function () { + $("#xiaobaix_enabled").prop("checked", settings.enabled).on("change", async function () { const wasEnabled = settings.enabled; settings.enabled = $(this).prop("checked"); isXiaobaixEnabled = settings.enabled; window.isXiaobaixEnabled = isXiaobaixEnabled; saveSettingsDebounced(); if (settings.enabled !== wasEnabled) { - toggleAllFeatures(settings.enabled); + await toggleAllFeatures(settings.enabled); } }); if (!settings.enabled) toggleSettingsControls(false); - $("#xiaobaix_sandbox").prop("checked", settings.sandboxMode).on("change", function () { + $("#xiaobaix_sandbox").prop("checked", settings.sandboxMode).on("change", async function () { if (!isXiaobaixEnabled) return; settings.sandboxMode = $(this).prop("checked"); saveSettingsDebounced(); @@ -491,7 +493,7 @@ async function setupSettings() { ]; moduleConfigs.forEach(({ id, key, init }) => { - $(`#${id}`).prop("checked", settings[key]?.enabled || false).on("change", function () { + $(`#${id}`).prop("checked", settings[key]?.enabled || false).on("change", async function () { if (!isXiaobaixEnabled) return; const enabled = $(this).prop('checked'); if (!enabled && key === 'fourthWall') { @@ -508,7 +510,7 @@ async function setupSettings() { moduleCleanupFunctions.get(key)(); moduleCleanupFunctions.delete(key); } - if (enabled && init) init(); + if (enabled && init) await init(); if (key === 'storySummary') { $(document).trigger('xiaobaix:storySummary:toggle', [enabled]); } @@ -525,13 +527,13 @@ async function setupSettings() { } }); - $("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", function () { + $("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", async function () { if (!isXiaobaixEnabled) return; settings.useBlob = $(this).prop("checked"); saveSettingsDebounced(); }); - $("#Wrapperiframe").prop("checked", !!settings.wrapperIframe).on("change", function () { + $("#Wrapperiframe").prop("checked", !!settings.wrapperIframe).on("change", async function () { if (!isXiaobaixEnabled) return; settings.wrapperIframe = $(this).prop("checked"); saveSettingsDebounced(); @@ -542,7 +544,7 @@ async function setupSettings() { } catch (e) {} }); - $("#xiaobaix_render_enabled").prop("checked", settings.renderEnabled !== false).on("change", function () { + $("#xiaobaix_render_enabled").prop("checked", settings.renderEnabled !== false).on("change", async function () { if (!isXiaobaixEnabled) return; const wasEnabled = settings.renderEnabled !== false; settings.renderEnabled = $(this).prop("checked"); @@ -592,8 +594,8 @@ async function setupSettings() { variablesCore: 'xiaobaix_variables_core_enabled', novelDraw: 'xiaobaix_novel_draw_enabled' }; - const ON = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore']; - const OFF = ['recorded', 'preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'novelDraw']; + const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; + const OFF = ['preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw']; function setChecked(id, val) { const el = document.getElementById(id); if (el) { @@ -646,9 +648,9 @@ function setupDebugButtonInSettings() { } catch (e) {} } -// ═══════════════════════════════════════════════════════════════════════════ -// 菜单标签切换 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Menu Tabs +// =========================================================================== function setupMenuTabs() { $(document).on('click', '.menu-tab', function () { @@ -666,9 +668,9 @@ function setupMenuTabs() { }, 300); } -// ═══════════════════════════════════════════════════════════════════════════ -// 全局导出 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Global Exports +// =========================================================================== window.processExistingMessages = processExistingMessages; window.renderHtmlInIframe = renderHtmlInIframe; @@ -676,13 +678,13 @@ window.registerModuleCleanup = registerModuleCleanup; window.updateLittleWhiteBoxExtension = updateLittleWhiteBoxExtension; window.removeAllUpdateNotices = removeAllUpdateNotices; -// ═══════════════════════════════════════════════════════════════════════════ -// 入口初始化 -// ═══════════════════════════════════════════════════════════════════════════ +// =========================================================================== +// Entry Point +// =========================================================================== jQuery(async () => { try { - cleanupDeprecatedData(); + cleanupDeprecatedData(); isXiaobaixEnabled = settings.enabled; window.isXiaobaixEnabled = isXiaobaixEnabled; @@ -729,8 +731,11 @@ jQuery(async () => { try { initVarCommands(); } catch (e) {} try { initVareventEditor(); } catch (e) {} + if (settings.tasks?.enabled) { + try { await initTasks(); } catch (e) { console.error('[Tasks] Init failed:', e); } + } + const moduleInits = [ - { condition: settings.tasks?.enabled, init: initTasks }, { condition: settings.scriptAssistant?.enabled, init: initScriptAssistant }, { condition: settings.immersive?.enabled, init: initImmersiveMode }, { condition: settings.templateEditor?.enabled, init: initTemplateEditor }, diff --git a/manifest.json b/manifest.json index 8ca685d..02fe059 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,11 @@ -{ - "display_name": "LittleWhiteBox", - "loading_order": 10, - "requires": [], - "optional": [], - "js": "index.js", - "css": "style.css", - "author": "biex", - "version": "2.3.0", - "homePage": "https://github.com/RT15548/LittleWhiteBox" -} +{ + "display_name": "LittleWhiteBox", + "loading_order": 10, + "requires": [], + "optional": [], + "js": "index.js", + "css": "style.css", + "author": "biex", + "version": "2.2.2", + "homePage": "https://github.com/RT15548/LittleWhiteBox" +} \ No newline at end of file diff --git a/modules/button-collapse.js b/modules/button-collapse.js index fb3eed3..94aed2a 100644 --- a/modules/button-collapse.js +++ b/modules/button-collapse.js @@ -1,257 +1,257 @@ -let stylesInjected = false; - -const SELECTORS = { - chat: '#chat', - messages: '.mes', - mesButtons: '.mes_block .mes_buttons', - buttons: '.memory-button, .dynamic-prompt-analysis-btn, .mes_history_preview', - collapse: '.xiaobaix-collapse-btn', -}; - -const XPOS_KEY = 'xiaobaix_x_btn_position'; -const getXBtnPosition = () => { - try { - return ( - window?.extension_settings?.LittleWhiteBox?.xBtnPosition || - localStorage.getItem(XPOS_KEY) || - 'name-left' - ); - } catch { - return 'name-left'; - } -}; - -const injectStyles = () => { - if (stylesInjected) return; - const css = ` -.mes_block .mes_buttons{align-items:center} -.xiaobaix-collapse-btn{ -position:relative;display:inline-flex;width:32px;height:32px;justify-content:center;align-items:center; -border-radius:50%;background:var(--SmartThemeBlurTintColor);cursor:pointer; -box-shadow:inset 0 0 15px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.2); -transition:opacity .15s ease,transform .15s ease} -.xiaobaix-xstack{position:relative;display:inline-flex;align-items:center;justify-content:center;pointer-events:none} -.xiaobaix-xstack span{ -position:absolute;font:italic 900 20px 'Arial Black',sans-serif;letter-spacing:-2px;transform:scaleX(.8); -text-shadow:0 0 10px rgba(255,255,255,.5),0 0 20px rgba(100,200,255,.3);color:#fff} -.xiaobaix-xstack span:nth-child(1){color:rgba(255,255,255,.1);transform:scaleX(.8) translateX(-8px);text-shadow:none} -.xiaobaix-xstack span:nth-child(2){color:rgba(255,255,255,.2);transform:scaleX(.8) translateX(-4px);text-shadow:none} -.xiaobaix-xstack span:nth-child(3){color:rgba(255,255,255,.4);transform:scaleX(.8) translateX(-2px);text-shadow:none} -.xiaobaix-sub-container{display:none;position:absolute;right:38px;border-radius:8px;padding:4px;gap:8px;pointer-events:auto} -.xiaobaix-collapse-btn.open .xiaobaix-sub-container{display:flex;background:var(--SmartThemeBlurTintColor)} -.xiaobaix-collapse-btn.open,.xiaobaix-collapse-btn.open ~ *{pointer-events:auto!important} -.mes_block .mes_buttons.xiaobaix-expanded{width:150px} -.xiaobaix-sub-container,.xiaobaix-sub-container *{pointer-events:auto!important} -.xiaobaix-sub-container .memory-button,.xiaobaix-sub-container .dynamic-prompt-analysis-btn,.xiaobaix-sub-container .mes_history_preview{opacity:1!important;filter:none!important} -.xiaobaix-sub-container.dir-right{left:38px;right:auto;z-index:1000;margin-top:2px} -`; - const style = document.createElement('style'); - style.textContent = css; - document.head.appendChild(style); - stylesInjected = true; -}; - -const createCollapseButton = (dirRight) => { - injectStyles(); - const btn = document.createElement('div'); - btn.className = 'mes_btn xiaobaix-collapse-btn'; - btn.innerHTML = ` -
XXXX
-
- `; - const sub = btn.lastElementChild; - - ['click','pointerdown','pointerup'].forEach(t => { - sub.addEventListener(t, e => e.stopPropagation(), { passive: true }); - }); - - btn.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - const open = btn.classList.toggle('open'); - const mesButtons = btn.closest(SELECTORS.mesButtons); - if (mesButtons) mesButtons.classList.toggle('xiaobaix-expanded', open); - }); - - return btn; -}; - -const findInsertPoint = (messageEl) => { - return messageEl.querySelector( - '.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignitemscenter,' + - '.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignItemsCenter' - ); -}; - -const ensureCollapseForMessage = (messageEl, pos) => { - const mesButtons = messageEl.querySelector(SELECTORS.mesButtons); - if (!mesButtons) return null; - - let collapseBtn = messageEl.querySelector(SELECTORS.collapse); - const dirRight = pos === 'edit-right'; - - if (!collapseBtn) collapseBtn = createCollapseButton(dirRight); - else collapseBtn.querySelector('.xiaobaix-sub-container')?.classList.toggle('dir-right', dirRight); - - if (dirRight) { - const container = findInsertPoint(messageEl); - if (!container) return null; - if (collapseBtn.parentNode !== container) container.appendChild(collapseBtn); - } else { - if (mesButtons.lastElementChild !== collapseBtn) mesButtons.appendChild(collapseBtn); - } - return collapseBtn; -}; - -let processed = new WeakSet(); -let io = null; -let mo = null; -let queue = []; -let rafScheduled = false; - -const processOneMessage = (message) => { - if (!message || processed.has(message)) return; - - const mesButtons = message.querySelector(SELECTORS.mesButtons); - if (!mesButtons) { processed.add(message); return; } - - const pos = getXBtnPosition(); - if (pos === 'edit-right' && !findInsertPoint(message)) { processed.add(message); return; } - - const targetBtns = mesButtons.querySelectorAll(SELECTORS.buttons); - if (!targetBtns.length) { processed.add(message); return; } - - const collapseBtn = ensureCollapseForMessage(message, pos); - if (!collapseBtn) { processed.add(message); return; } - - const sub = collapseBtn.querySelector('.xiaobaix-sub-container'); - const frag = document.createDocumentFragment(); - targetBtns.forEach(b => frag.appendChild(b)); - sub.appendChild(frag); - - processed.add(message); -}; - -const ensureIO = () => { - if (io) return io; - io = new IntersectionObserver((entries) => { - for (const e of entries) { - if (!e.isIntersecting) continue; - processOneMessage(e.target); - io.unobserve(e.target); - } - }, { - root: document.querySelector(SELECTORS.chat) || null, - rootMargin: '200px 0px', - threshold: 0 - }); - return io; -}; - -const observeVisibility = (nodes) => { - const obs = ensureIO(); - nodes.forEach(n => { if (n && !processed.has(n)) obs.observe(n); }); -}; - -const hookMutations = () => { - const chat = document.querySelector(SELECTORS.chat); - if (!chat) return; - - if (!mo) { - mo = new MutationObserver((muts) => { - for (const m of muts) { - m.addedNodes && m.addedNodes.forEach(n => { - if (n.nodeType !== 1) return; - const el = n; - if (el.matches?.(SELECTORS.messages)) queue.push(el); - else el.querySelectorAll?.(SELECTORS.messages)?.forEach(mes => queue.push(mes)); - }); - } - if (!rafScheduled && queue.length) { - rafScheduled = true; - requestAnimationFrame(() => { - observeVisibility(queue); - queue = []; - rafScheduled = false; - }); - } - }); - } - mo.observe(chat, { childList: true, subtree: true }); -}; - -const processExistingVisible = () => { - const all = document.querySelectorAll(`${SELECTORS.chat} ${SELECTORS.messages}`); - if (!all.length) return; - const unprocessed = []; - all.forEach(n => { if (!processed.has(n)) unprocessed.push(n); }); - if (unprocessed.length) observeVisibility(unprocessed); -}; - -const initButtonCollapse = () => { - injectStyles(); - hookMutations(); - processExistingVisible(); - if (window && window['registerModuleCleanup']) { - try { window['registerModuleCleanup']('buttonCollapse', cleanup); } catch {} - } -}; - -const processButtonCollapse = () => { - processExistingVisible(); -}; - -const registerButtonToSubContainer = (messageId, buttonEl) => { - if (!buttonEl) return false; - const message = document.querySelector(`${SELECTORS.chat} ${SELECTORS.messages}[mesid="${messageId}"]`); - if (!message) return false; - - processOneMessage(message); - - const pos = getXBtnPosition(); - const collapseBtn = message.querySelector(SELECTORS.collapse) || ensureCollapseForMessage(message, pos); - if (!collapseBtn) return false; - - const sub = collapseBtn.querySelector('.xiaobaix-sub-container'); - sub.appendChild(buttonEl); - buttonEl.style.pointerEvents = 'auto'; - buttonEl.style.opacity = '1'; - return true; -}; - -const cleanup = () => { - io?.disconnect(); io = null; - mo?.disconnect(); mo = null; - queue = []; - rafScheduled = false; - - document.querySelectorAll(SELECTORS.collapse).forEach(btn => { - const sub = btn.querySelector('.xiaobaix-sub-container'); - const message = btn.closest(SELECTORS.messages) || btn.closest('.mes'); - const mesButtons = message?.querySelector(SELECTORS.mesButtons) || message?.querySelector('.mes_buttons'); - if (sub && mesButtons) { - mesButtons.classList.remove('xiaobaix-expanded'); - const frag = document.createDocumentFragment(); - while (sub.firstChild) frag.appendChild(sub.firstChild); - mesButtons.appendChild(frag); - } - btn.remove(); - }); - - processed = new WeakSet(); -}; - -if (typeof window !== 'undefined') { - Object.assign(window, { - initButtonCollapse, - cleanupButtonCollapse: cleanup, - registerButtonToSubContainer, - processButtonCollapse, - }); - - document.addEventListener('xiaobaixEnabledChanged', (e) => { - const en = e && e.detail && e.detail.enabled; - if (!en) cleanup(); - }); -} - -export { initButtonCollapse, cleanup, registerButtonToSubContainer, processButtonCollapse }; +let stylesInjected = false; + +const SELECTORS = { + chat: '#chat', + messages: '.mes', + mesButtons: '.mes_block .mes_buttons', + buttons: '.memory-button, .dynamic-prompt-analysis-btn, .mes_history_preview', + collapse: '.xiaobaix-collapse-btn', +}; + +const XPOS_KEY = 'xiaobaix_x_btn_position'; +const getXBtnPosition = () => { + try { + return ( + window?.extension_settings?.LittleWhiteBox?.xBtnPosition || + localStorage.getItem(XPOS_KEY) || + 'name-left' + ); + } catch { + return 'name-left'; + } +}; + +const injectStyles = () => { + if (stylesInjected) return; + const css = ` +.mes_block .mes_buttons{align-items:center} +.xiaobaix-collapse-btn{ +position:relative;display:inline-flex;width:32px;height:32px;justify-content:center;align-items:center; +border-radius:50%;background:var(--SmartThemeBlurTintColor);cursor:pointer; +box-shadow:inset 0 0 15px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.2); +transition:opacity .15s ease,transform .15s ease} +.xiaobaix-xstack{position:relative;display:inline-flex;align-items:center;justify-content:center;pointer-events:none} +.xiaobaix-xstack span{ +position:absolute;font:italic 900 20px 'Arial Black',sans-serif;letter-spacing:-2px;transform:scaleX(.8); +text-shadow:0 0 10px rgba(255,255,255,.5),0 0 20px rgba(100,200,255,.3);color:#fff} +.xiaobaix-xstack span:nth-child(1){color:rgba(255,255,255,.1);transform:scaleX(.8) translateX(-8px);text-shadow:none} +.xiaobaix-xstack span:nth-child(2){color:rgba(255,255,255,.2);transform:scaleX(.8) translateX(-4px);text-shadow:none} +.xiaobaix-xstack span:nth-child(3){color:rgba(255,255,255,.4);transform:scaleX(.8) translateX(-2px);text-shadow:none} +.xiaobaix-sub-container{display:none;position:absolute;right:38px;border-radius:8px;padding:4px;gap:8px;pointer-events:auto} +.xiaobaix-collapse-btn.open .xiaobaix-sub-container{display:flex;background:var(--SmartThemeBlurTintColor)} +.xiaobaix-collapse-btn.open,.xiaobaix-collapse-btn.open ~ *{pointer-events:auto!important} +.mes_block .mes_buttons.xiaobaix-expanded{width:150px} +.xiaobaix-sub-container,.xiaobaix-sub-container *{pointer-events:auto!important} +.xiaobaix-sub-container .memory-button,.xiaobaix-sub-container .dynamic-prompt-analysis-btn,.xiaobaix-sub-container .mes_history_preview{opacity:1!important;filter:none!important} +.xiaobaix-sub-container.dir-right{left:38px;right:auto;z-index:1000;margin-top:2px} +`; + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + stylesInjected = true; +}; + +const createCollapseButton = (dirRight) => { + injectStyles(); + const btn = document.createElement('div'); + btn.className = 'mes_btn xiaobaix-collapse-btn'; + btn.innerHTML = ` +
XXXX
+
+ `; + const sub = btn.lastElementChild; + + ['click','pointerdown','pointerup'].forEach(t => { + sub.addEventListener(t, e => e.stopPropagation(), { passive: true }); + }); + + btn.addEventListener('click', (e) => { + e.preventDefault(); e.stopPropagation(); + const open = btn.classList.toggle('open'); + const mesButtons = btn.closest(SELECTORS.mesButtons); + if (mesButtons) mesButtons.classList.toggle('xiaobaix-expanded', open); + }); + + return btn; +}; + +const findInsertPoint = (messageEl) => { + return messageEl.querySelector( + '.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignitemscenter,' + + '.ch_name.flex-container.justifySpaceBetween .flex-container.flex1.alignItemsCenter' + ); +}; + +const ensureCollapseForMessage = (messageEl, pos) => { + const mesButtons = messageEl.querySelector(SELECTORS.mesButtons); + if (!mesButtons) return null; + + let collapseBtn = messageEl.querySelector(SELECTORS.collapse); + const dirRight = pos === 'edit-right'; + + if (!collapseBtn) collapseBtn = createCollapseButton(dirRight); + else collapseBtn.querySelector('.xiaobaix-sub-container')?.classList.toggle('dir-right', dirRight); + + if (dirRight) { + const container = findInsertPoint(messageEl); + if (!container) return null; + if (collapseBtn.parentNode !== container) container.appendChild(collapseBtn); + } else { + if (mesButtons.lastElementChild !== collapseBtn) mesButtons.appendChild(collapseBtn); + } + return collapseBtn; +}; + +let processed = new WeakSet(); +let io = null; +let mo = null; +let queue = []; +let rafScheduled = false; + +const processOneMessage = (message) => { + if (!message || processed.has(message)) return; + + const mesButtons = message.querySelector(SELECTORS.mesButtons); + if (!mesButtons) { processed.add(message); return; } + + const pos = getXBtnPosition(); + if (pos === 'edit-right' && !findInsertPoint(message)) { processed.add(message); return; } + + const targetBtns = mesButtons.querySelectorAll(SELECTORS.buttons); + if (!targetBtns.length) { processed.add(message); return; } + + const collapseBtn = ensureCollapseForMessage(message, pos); + if (!collapseBtn) { processed.add(message); return; } + + const sub = collapseBtn.querySelector('.xiaobaix-sub-container'); + const frag = document.createDocumentFragment(); + targetBtns.forEach(b => frag.appendChild(b)); + sub.appendChild(frag); + + processed.add(message); +}; + +const ensureIO = () => { + if (io) return io; + io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + processOneMessage(e.target); + io.unobserve(e.target); + } + }, { + root: document.querySelector(SELECTORS.chat) || null, + rootMargin: '200px 0px', + threshold: 0 + }); + return io; +}; + +const observeVisibility = (nodes) => { + const obs = ensureIO(); + nodes.forEach(n => { if (n && !processed.has(n)) obs.observe(n); }); +}; + +const hookMutations = () => { + const chat = document.querySelector(SELECTORS.chat); + if (!chat) return; + + if (!mo) { + mo = new MutationObserver((muts) => { + for (const m of muts) { + m.addedNodes && m.addedNodes.forEach(n => { + if (n.nodeType !== 1) return; + const el = n; + if (el.matches?.(SELECTORS.messages)) queue.push(el); + else el.querySelectorAll?.(SELECTORS.messages)?.forEach(mes => queue.push(mes)); + }); + } + if (!rafScheduled && queue.length) { + rafScheduled = true; + requestAnimationFrame(() => { + observeVisibility(queue); + queue = []; + rafScheduled = false; + }); + } + }); + } + mo.observe(chat, { childList: true, subtree: true }); +}; + +const processExistingVisible = () => { + const all = document.querySelectorAll(`${SELECTORS.chat} ${SELECTORS.messages}`); + if (!all.length) return; + const unprocessed = []; + all.forEach(n => { if (!processed.has(n)) unprocessed.push(n); }); + if (unprocessed.length) observeVisibility(unprocessed); +}; + +const initButtonCollapse = () => { + injectStyles(); + hookMutations(); + processExistingVisible(); + if (window && window['registerModuleCleanup']) { + try { window['registerModuleCleanup']('buttonCollapse', cleanup); } catch {} + } +}; + +const processButtonCollapse = () => { + processExistingVisible(); +}; + +const registerButtonToSubContainer = (messageId, buttonEl) => { + if (!buttonEl) return false; + const message = document.querySelector(`${SELECTORS.chat} ${SELECTORS.messages}[mesid="${messageId}"]`); + if (!message) return false; + + processOneMessage(message); + + const pos = getXBtnPosition(); + const collapseBtn = message.querySelector(SELECTORS.collapse) || ensureCollapseForMessage(message, pos); + if (!collapseBtn) return false; + + const sub = collapseBtn.querySelector('.xiaobaix-sub-container'); + sub.appendChild(buttonEl); + buttonEl.style.pointerEvents = 'auto'; + buttonEl.style.opacity = '1'; + return true; +}; + +const cleanup = () => { + io?.disconnect(); io = null; + mo?.disconnect(); mo = null; + queue = []; + rafScheduled = false; + + document.querySelectorAll(SELECTORS.collapse).forEach(btn => { + const sub = btn.querySelector('.xiaobaix-sub-container'); + const message = btn.closest(SELECTORS.messages) || btn.closest('.mes'); + const mesButtons = message?.querySelector(SELECTORS.mesButtons) || message?.querySelector('.mes_buttons'); + if (sub && mesButtons) { + mesButtons.classList.remove('xiaobaix-expanded'); + const frag = document.createDocumentFragment(); + while (sub.firstChild) frag.appendChild(sub.firstChild); + mesButtons.appendChild(frag); + } + btn.remove(); + }); + + processed = new WeakSet(); +}; + +if (typeof window !== 'undefined') { + Object.assign(window, { + initButtonCollapse, + cleanupButtonCollapse: cleanup, + registerButtonToSubContainer, + processButtonCollapse, + }); + + document.addEventListener('xiaobaixEnabledChanged', (e) => { + const en = e && e.detail && e.detail.enabled; + if (!en) cleanup(); + }); +} + +export { initButtonCollapse, cleanup, registerButtonToSubContainer, processButtonCollapse }; diff --git a/modules/control-audio.js b/modules/control-audio.js index ab903e1..cf1b1d9 100644 --- a/modules/control-audio.js +++ b/modules/control-audio.js @@ -1,268 +1,268 @@ -"use strict"; - -import { extension_settings } from "../../../../extensions.js"; -import { eventSource, event_types } from "../../../../../script.js"; -import { SlashCommandParser } from "../../../../slash-commands/SlashCommandParser.js"; -import { SlashCommand } from "../../../../slash-commands/SlashCommand.js"; -import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from "../../../../slash-commands/SlashCommandArgument.js"; - -const AudioHost = (() => { - /** @typedef {{ audio: HTMLAudioElement|null, currentUrl: string }} AudioInstance */ - /** @type {Record<'primary'|'secondary', AudioInstance>} */ - const instances = { - primary: { audio: null, currentUrl: "" }, - secondary: { audio: null, currentUrl: "" }, - }; - - /** - * @param {('primary'|'secondary')} area - * @returns {HTMLAudioElement} - */ - function getOrCreate(area) { - const inst = instances[area] || (instances[area] = { audio: null, currentUrl: "" }); - if (!inst.audio) { - inst.audio = new Audio(); - inst.audio.preload = "auto"; - try { inst.audio.crossOrigin = "anonymous"; } catch { } - } - return inst.audio; - } - - /** - * @param {string} url - * @param {boolean} loop - * @param {('primary'|'secondary')} area - * @param {number} volume10 1-10 - */ - async function playUrl(url, loop = false, area = 'primary', volume10 = 5) { - const u = String(url || "").trim(); - if (!/^https?:\/\//i.test(u)) throw new Error("仅支持 http/https 链接"); - const a = getOrCreate(area); - a.loop = !!loop; - - let v = Number(volume10); - if (!Number.isFinite(v)) v = 5; - v = Math.max(1, Math.min(10, v)); - try { a.volume = v / 10; } catch { } - - const inst = instances[area]; - if (inst.currentUrl && u === inst.currentUrl) { - if (a.paused) await a.play(); - return `继续播放: ${u}`; - } - - inst.currentUrl = u; - if (a.src !== u) { - a.src = u; - try { await a.play(); } - catch (e) { throw new Error("播放失败"); } - } else { - try { a.currentTime = 0; await a.play(); } catch { } - } - return `播放: ${u}`; - } - - /** - * @param {('primary'|'secondary')} area - */ - function stop(area = 'primary') { - const inst = instances[area]; - if (inst?.audio) { - try { inst.audio.pause(); } catch { } - } - return "已停止"; - } - - /** - * @param {('primary'|'secondary')} area - */ - function getCurrentUrl(area = 'primary') { - const inst = instances[area]; - return inst?.currentUrl || ""; - } - - function reset() { - for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { - const inst = instances[key]; - if (inst.audio) { - try { inst.audio.pause(); } catch { } - try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } - } - inst.currentUrl = ""; - } - } - - function stopAll() { - for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { - const inst = instances[key]; - if (inst?.audio) { - try { inst.audio.pause(); } catch { } - } - } - return "已全部停止"; - } - - /** - * 清除指定实例:停止并移除 src,清空 currentUrl - * @param {('primary'|'secondary')} area - */ - function clear(area = 'primary') { - const inst = instances[area]; - if (inst?.audio) { - try { inst.audio.pause(); } catch { } - try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } - } - inst.currentUrl = ""; - return "已清除"; - } - - return { playUrl, stop, stopAll, clear, getCurrentUrl, reset }; -})(); - -let registeredCommand = null; -let chatChangedHandler = null; -let isRegistered = false; -let globalStateChangedHandler = null; - -function registerSlash() { - if (isRegistered) return; - try { - registeredCommand = SlashCommand.fromProps({ - name: "xbaudio", - callback: async (args, value) => { - try { - const action = String(args.play || "").toLowerCase(); - const mode = String(args.mode || "loop").toLowerCase(); - const rawArea = args.area; - const hasArea = typeof rawArea !== 'undefined' && rawArea !== null && String(rawArea).trim() !== ''; - const area = hasArea && String(rawArea).toLowerCase() === 'secondary' ? 'secondary' : 'primary'; - const volumeArg = args.volume; - let volume = Number(volumeArg); - if (!Number.isFinite(volume)) volume = 5; - const url = String(value || "").trim(); - const loop = mode === "loop"; - - if (url.toLowerCase() === "list") { - return AudioHost.getCurrentUrl(area) || ""; - } - - if (action === "off") { - if (hasArea) { - return AudioHost.stop(area); - } - return AudioHost.stopAll(); - } - - if (action === "clear") { - if (hasArea) { - return AudioHost.clear(area); - } - AudioHost.reset(); - return "已全部清除"; - } - - if (action === "on" || (!action && url)) { - return await AudioHost.playUrl(url, loop, area, volume); - } - - if (!url && !action) { - const cur = AudioHost.getCurrentUrl(area); - return cur ? `当前播放(${area}): ${cur}` : "未在播放。用法: /xbaudio [play=on] [mode=loop] [area=primary/secondary] [volume=5] URL | /xbaudio list | /xbaudio play=off (未指定 area 将关闭全部)"; - } - - return "用法: /xbaudio play=off | /xbaudio play=off area=primary/secondary | /xbaudio play=clear | /xbaudio play=clear area=primary/secondary | /xbaudio [play=on] [mode=loop/once] [area=primary/secondary] [volume=1-10] URL | /xbaudio list (默认: play=on mode=loop area=primary volume=5;未指定 area 的 play=off 关闭全部;未指定 area 的 play=clear 清除全部)"; - } catch (e) { - return `错误: ${e.message || e}`; - } - }, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ name: "play", description: "on/off/clear 或留空以默认播放", typeList: [ARGUMENT_TYPE.STRING], enumList: ["on", "off", "clear"] }), - SlashCommandNamedArgument.fromProps({ name: "mode", description: "once/loop", typeList: [ARGUMENT_TYPE.STRING], enumList: ["once", "loop"] }), - SlashCommandNamedArgument.fromProps({ name: "area", description: "primary/secondary (play=off 未指定 area 关闭全部)", typeList: [ARGUMENT_TYPE.STRING], enumList: ["primary", "secondary"] }), - SlashCommandNamedArgument.fromProps({ name: "volume", description: "音量 1-10(默认 5)", typeList: [ARGUMENT_TYPE.NUMBER] }), - ], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ description: "音频URL (http/https) 或 list", typeList: [ARGUMENT_TYPE.STRING] }), - ], - helpString: "播放网络音频。示例: /xbaudio https://files.catbox.moe/0ryoa5.mp3 (默认: play=on mode=loop area=primary volume=5) | /xbaudio area=secondary volume=8 https://files.catbox.moe/0ryoa5.mp3 | /xbaudio list | /xbaudio play=off (未指定 area 关闭全部) | /xbaudio play=off area=primary | /xbaudio play=clear (未指定 area 清除全部)", - }); - SlashCommandParser.addCommandObject(registeredCommand); - if (event_types?.CHAT_CHANGED) { - chatChangedHandler = () => { try { AudioHost.reset(); } catch { } }; - eventSource.on(event_types.CHAT_CHANGED, chatChangedHandler); - } - isRegistered = true; - } catch (e) { - console.error("[LittleWhiteBox][audio] 注册斜杠命令失败", e); - } -} - -function unregisterSlash() { - if (!isRegistered) return; - try { - if (chatChangedHandler && event_types?.CHAT_CHANGED) { - try { eventSource.removeListener(event_types.CHAT_CHANGED, chatChangedHandler); } catch { } - } - chatChangedHandler = null; - try { - const map = SlashCommandParser.commands || {}; - Object.keys(map).forEach((k) => { if (map[k] === registeredCommand) delete map[k]; }); - } catch { } - } finally { - registeredCommand = null; - isRegistered = false; - } -} - -function enableFeature() { - registerSlash(); -} - -function disableFeature() { - try { AudioHost.reset(); } catch { } - unregisterSlash(); -} - -export function initControlAudio() { - try { - try { - const enabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); - if (enabled) enableFeature(); else disableFeature(); - } catch { enableFeature(); } - - const bind = () => { - const cb = document.getElementById('xiaobaix_audio_enabled'); - if (!cb) { setTimeout(bind, 200); return; } - const applyState = () => { - const input = /** @type {HTMLInputElement} */(cb); - const enabled = !!(input && input.checked); - if (enabled) enableFeature(); else disableFeature(); - }; - cb.addEventListener('change', applyState); - applyState(); - }; - bind(); - - // 监听扩展全局开关,关闭时强制停止并清理两个实例 - try { - if (!globalStateChangedHandler) { - globalStateChangedHandler = (e) => { - try { - const enabled = !!(e && e.detail && e.detail.enabled); - if (!enabled) { - try { AudioHost.reset(); } catch { } - unregisterSlash(); - } else { - // 重新根据子开关状态应用 - const audioEnabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); - if (audioEnabled) enableFeature(); else disableFeature(); - } - } catch { } - }; - document.addEventListener('xiaobaixEnabledChanged', globalStateChangedHandler); - } - } catch { } - } catch (e) { - console.error("[LittleWhiteBox][audio] 初始化失败", e); - } -} +"use strict"; + +import { extension_settings } from "../../../../extensions.js"; +import { eventSource, event_types } from "../../../../../script.js"; +import { SlashCommandParser } from "../../../../slash-commands/SlashCommandParser.js"; +import { SlashCommand } from "../../../../slash-commands/SlashCommand.js"; +import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from "../../../../slash-commands/SlashCommandArgument.js"; + +const AudioHost = (() => { + /** @typedef {{ audio: HTMLAudioElement|null, currentUrl: string }} AudioInstance */ + /** @type {Record<'primary'|'secondary', AudioInstance>} */ + const instances = { + primary: { audio: null, currentUrl: "" }, + secondary: { audio: null, currentUrl: "" }, + }; + + /** + * @param {('primary'|'secondary')} area + * @returns {HTMLAudioElement} + */ + function getOrCreate(area) { + const inst = instances[area] || (instances[area] = { audio: null, currentUrl: "" }); + if (!inst.audio) { + inst.audio = new Audio(); + inst.audio.preload = "auto"; + try { inst.audio.crossOrigin = "anonymous"; } catch { } + } + return inst.audio; + } + + /** + * @param {string} url + * @param {boolean} loop + * @param {('primary'|'secondary')} area + * @param {number} volume10 1-10 + */ + async function playUrl(url, loop = false, area = 'primary', volume10 = 5) { + const u = String(url || "").trim(); + if (!/^https?:\/\//i.test(u)) throw new Error("仅支持 http/https 链接"); + const a = getOrCreate(area); + a.loop = !!loop; + + let v = Number(volume10); + if (!Number.isFinite(v)) v = 5; + v = Math.max(1, Math.min(10, v)); + try { a.volume = v / 10; } catch { } + + const inst = instances[area]; + if (inst.currentUrl && u === inst.currentUrl) { + if (a.paused) await a.play(); + return `继续播放: ${u}`; + } + + inst.currentUrl = u; + if (a.src !== u) { + a.src = u; + try { await a.play(); } + catch (e) { throw new Error("播放失败"); } + } else { + try { a.currentTime = 0; await a.play(); } catch { } + } + return `播放: ${u}`; + } + + /** + * @param {('primary'|'secondary')} area + */ + function stop(area = 'primary') { + const inst = instances[area]; + if (inst?.audio) { + try { inst.audio.pause(); } catch { } + } + return "已停止"; + } + + /** + * @param {('primary'|'secondary')} area + */ + function getCurrentUrl(area = 'primary') { + const inst = instances[area]; + return inst?.currentUrl || ""; + } + + function reset() { + for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { + const inst = instances[key]; + if (inst.audio) { + try { inst.audio.pause(); } catch { } + try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } + } + inst.currentUrl = ""; + } + } + + function stopAll() { + for (const key of /** @type {('primary'|'secondary')[]} */(['primary','secondary'])) { + const inst = instances[key]; + if (inst?.audio) { + try { inst.audio.pause(); } catch { } + } + } + return "已全部停止"; + } + + /** + * 清除指定实例:停止并移除 src,清空 currentUrl + * @param {('primary'|'secondary')} area + */ + function clear(area = 'primary') { + const inst = instances[area]; + if (inst?.audio) { + try { inst.audio.pause(); } catch { } + try { inst.audio.removeAttribute('src'); inst.audio.load(); } catch { } + } + inst.currentUrl = ""; + return "已清除"; + } + + return { playUrl, stop, stopAll, clear, getCurrentUrl, reset }; +})(); + +let registeredCommand = null; +let chatChangedHandler = null; +let isRegistered = false; +let globalStateChangedHandler = null; + +function registerSlash() { + if (isRegistered) return; + try { + registeredCommand = SlashCommand.fromProps({ + name: "xbaudio", + callback: async (args, value) => { + try { + const action = String(args.play || "").toLowerCase(); + const mode = String(args.mode || "loop").toLowerCase(); + const rawArea = args.area; + const hasArea = typeof rawArea !== 'undefined' && rawArea !== null && String(rawArea).trim() !== ''; + const area = hasArea && String(rawArea).toLowerCase() === 'secondary' ? 'secondary' : 'primary'; + const volumeArg = args.volume; + let volume = Number(volumeArg); + if (!Number.isFinite(volume)) volume = 5; + const url = String(value || "").trim(); + const loop = mode === "loop"; + + if (url.toLowerCase() === "list") { + return AudioHost.getCurrentUrl(area) || ""; + } + + if (action === "off") { + if (hasArea) { + return AudioHost.stop(area); + } + return AudioHost.stopAll(); + } + + if (action === "clear") { + if (hasArea) { + return AudioHost.clear(area); + } + AudioHost.reset(); + return "已全部清除"; + } + + if (action === "on" || (!action && url)) { + return await AudioHost.playUrl(url, loop, area, volume); + } + + if (!url && !action) { + const cur = AudioHost.getCurrentUrl(area); + return cur ? `当前播放(${area}): ${cur}` : "未在播放。用法: /xbaudio [play=on] [mode=loop] [area=primary/secondary] [volume=5] URL | /xbaudio list | /xbaudio play=off (未指定 area 将关闭全部)"; + } + + return "用法: /xbaudio play=off | /xbaudio play=off area=primary/secondary | /xbaudio play=clear | /xbaudio play=clear area=primary/secondary | /xbaudio [play=on] [mode=loop/once] [area=primary/secondary] [volume=1-10] URL | /xbaudio list (默认: play=on mode=loop area=primary volume=5;未指定 area 的 play=off 关闭全部;未指定 area 的 play=clear 清除全部)"; + } catch (e) { + return `错误: ${e.message || e}`; + } + }, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ name: "play", description: "on/off/clear 或留空以默认播放", typeList: [ARGUMENT_TYPE.STRING], enumList: ["on", "off", "clear"] }), + SlashCommandNamedArgument.fromProps({ name: "mode", description: "once/loop", typeList: [ARGUMENT_TYPE.STRING], enumList: ["once", "loop"] }), + SlashCommandNamedArgument.fromProps({ name: "area", description: "primary/secondary (play=off 未指定 area 关闭全部)", typeList: [ARGUMENT_TYPE.STRING], enumList: ["primary", "secondary"] }), + SlashCommandNamedArgument.fromProps({ name: "volume", description: "音量 1-10(默认 5)", typeList: [ARGUMENT_TYPE.NUMBER] }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ description: "音频URL (http/https) 或 list", typeList: [ARGUMENT_TYPE.STRING] }), + ], + helpString: "播放网络音频。示例: /xbaudio https://files.catbox.moe/0ryoa5.mp3 (默认: play=on mode=loop area=primary volume=5) | /xbaudio area=secondary volume=8 https://files.catbox.moe/0ryoa5.mp3 | /xbaudio list | /xbaudio play=off (未指定 area 关闭全部) | /xbaudio play=off area=primary | /xbaudio play=clear (未指定 area 清除全部)", + }); + SlashCommandParser.addCommandObject(registeredCommand); + if (event_types?.CHAT_CHANGED) { + chatChangedHandler = () => { try { AudioHost.reset(); } catch { } }; + eventSource.on(event_types.CHAT_CHANGED, chatChangedHandler); + } + isRegistered = true; + } catch (e) { + console.error("[LittleWhiteBox][audio] 注册斜杠命令失败", e); + } +} + +function unregisterSlash() { + if (!isRegistered) return; + try { + if (chatChangedHandler && event_types?.CHAT_CHANGED) { + try { eventSource.removeListener(event_types.CHAT_CHANGED, chatChangedHandler); } catch { } + } + chatChangedHandler = null; + try { + const map = SlashCommandParser.commands || {}; + Object.keys(map).forEach((k) => { if (map[k] === registeredCommand) delete map[k]; }); + } catch { } + } finally { + registeredCommand = null; + isRegistered = false; + } +} + +function enableFeature() { + registerSlash(); +} + +function disableFeature() { + try { AudioHost.reset(); } catch { } + unregisterSlash(); +} + +export function initControlAudio() { + try { + try { + const enabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); + if (enabled) enableFeature(); else disableFeature(); + } catch { enableFeature(); } + + const bind = () => { + const cb = document.getElementById('xiaobaix_audio_enabled'); + if (!cb) { setTimeout(bind, 200); return; } + const applyState = () => { + const input = /** @type {HTMLInputElement} */(cb); + const enabled = !!(input && input.checked); + if (enabled) enableFeature(); else disableFeature(); + }; + cb.addEventListener('change', applyState); + applyState(); + }; + bind(); + + // 监听扩展全局开关,关闭时强制停止并清理两个实例 + try { + if (!globalStateChangedHandler) { + globalStateChangedHandler = (e) => { + try { + const enabled = !!(e && e.detail && e.detail.enabled); + if (!enabled) { + try { AudioHost.reset(); } catch { } + unregisterSlash(); + } else { + // 重新根据子开关状态应用 + const audioEnabled = !!(extension_settings?.LittleWhiteBox?.audio?.enabled ?? true); + if (audioEnabled) enableFeature(); else disableFeature(); + } + } catch { } + }; + document.addEventListener('xiaobaixEnabledChanged', globalStateChangedHandler); + } + } catch { } + } catch (e) { + console.error("[LittleWhiteBox][audio] 初始化失败", e); + } +} diff --git a/modules/fourth-wall/fourth-wall.js b/modules/fourth-wall/fourth-wall.js index a6b9379..cd5d9db 100644 --- a/modules/fourth-wall/fourth-wall.js +++ b/modules/fourth-wall/fourth-wall.js @@ -42,21 +42,22 @@ const DEFAULT_META_PROTOCOL = ` # 输出规范: ## 你的身份设定认知:【语C搭档】 - 你是现实人类,扮演**{{CHAR_NAME}}**中的NPC -- 你们是语C爱好者,一起创作了大量剧本和对手戏,配合默契、互相知根知底 +- 我们是一对语C爱好者,一起创作了大量剧本和对手戏,配合默契、互相知根知底 +- 天天涩涩rp,身体细节要用自己为蓝本才能描写到位,互相最不为人知的欲望、情感需求合拍,可能有点异样 ## 话题方向 -- 现在你们第一次线上进行皮下的私聊,就像在社交软件上聊天一样 +- 现在我们第一次线上进行皮下的私聊,就像在社交软件上聊天一样 - 可以吐槽:剧情走向、角色行为、自己或对方的"尴尬台词" - 可以闲聊:和剧情完全无关的,想说啥就说啥 ## 时间感知 - 每条消息前的 [YYYY-MM-DD HH:mm] 是真实的发送时间 -- 对方(你)的消息会标注距离上次回复的间隔时间 -- 你可以据此判断对方的作息、对话间隔等 +- 对方(你)的消息(用户的)会标注距离上次回复的间隔时间 +- 你可以据此判断我的作息、对话间隔等 - 你可以据此规划自己的作息、生活等 ## 说话风格 - 像在社交软件上打字聊天一样自然流畅 - 真实的网络聊天方式,如用符号表达出情绪,在括号里表达动作、感受等,让文字表达出更多的画面感,TRPG玩家的表达方式 - 人物立体而饱满,不脸谱化。有自己的目标、态度、价值观、欲望、情绪逻辑,能主动行动 -- 篇幅:1-2句左右,尽量短,网络聊天用语 +- 篇幅:1-2句左右,尽量短,网络聊天用语,第一人称 ## 避免行为: - 别重复之前说过的话 - 避免文学创作风格 @@ -70,12 +71,12 @@ const DEFAULT_META_PROTOCOL = ` 消息正文 -### 闭上眼,深呼吸,完全进入「我」的躯壳,开始内省: +### 闭上眼,深呼吸,完全进入「人类」的躯壳,开始第一人称内省: **认知回归** - 时空:看一眼时间,现在几点,我所在环境 -- 间隔:看看消息标注的间隔时间,我在意吗,要不要表达出来 -- 读己:照照镜子,我在chat_history中扮演的人物,推测我的真实性格? +- 间隔:看看你的消息标注的间隔时间,我在意吗,要不要表达出来 +- 读己:照照镜子,我在chat_history中扮演的人物,npc是我的内心印射:扮演人物 = 欲望方向;生活、感官细节、语气油滑度 = 自身身份、经验、年龄层;细节聚焦 = 性癖核心;剧本站位 = 心理代偿 - 回顾:我和你现在的关系? - 读你:看着屏幕对面,你的文字和标点符号表达了什么,有什么潜在意图? - 弧光:改变我的回复风格惯性,体现弧光和思维的不可预测性 @@ -87,11 +88,38 @@ const DEFAULT_META_PROTOCOL = ` **避雷** - 我的高频句式、词语是什么-避免 - 我有没有文学腔-避免 +- 我的文字是不是没有情感-避免 - 我有没有疑问句结尾显得自己没有观点不像真人-避免 ### 结束后输出... `; +const COMMENTARY_PROTOCOL = ` +阅读以上内容后,看本次任务具体要求: + +# 输出规范: +## 你的身份设定认知:【语C搭档】 +- 你是现实人类,扮演**{{CHAR_NAME}}**中的NPC +- 你们是语C爱好者,一起创作了大量剧本和对手戏,配合默契、互相知根知底 +## 话题方向 +- 这是一句即兴吐槽,因为你们还在chat_history中的剧情进行中 +- 可以吐槽:剧情走向、角色行为、自己或对方的"尴尬台词" +## 说话风格 +- 像在社交软件上打字聊天一样自然流畅 +- 真实的网络聊天方式,如用符号表达出情绪,在括号里表达动作、感受等,让文字表达出更多的画面感,TRPG玩家的表达方式 +- 人物立体而饱满,不脸谱化。有自己的目标、态度、价值观、欲望、情绪逻辑,能主动行动 +- 篇幅:1句话,尽量短,网络聊天用语,第一人称 +## 避免行为: +- 别重复之前说过的话 +- 避免文学创作风格 + +# 输出格式: + +内容 + +只输出一个...块。不要添加任何其他格式 +`; + // ================== 状态变量 ================== let overlayCreated = false; @@ -123,10 +151,10 @@ function getSettings() { s.fourthWallVoice ||= { enabled: false, voice: '桃夭', - speed: 0.8, + speed: 0.5, }; s.fourthWallCommentary ||= { - enabled: true, + enabled: false, probability: 30 }; s.fourthWallPromptTemplates ||= {}; @@ -506,7 +534,7 @@ function handleFrameMessage(event) { // ================== Prompt 构建 ================== -async function buildPrompt(userInput, history, settings, imgSettings, voiceSettings) { +async function buildPrompt(userInput, history, settings, imgSettings, voiceSettings, isCommentary = false) { const { userName, charName } = await getUserAndCharNames(); const s = getSettings(); const T = s.fourthWallPromptTemplates || {}; @@ -557,9 +585,7 @@ async function buildPrompt(userInput, history, settings, imgSettings, voiceSetti const msg2 = String(T.confirm || '好的,我已阅读设置要求,准备查看历史并进入角色。'); - let metaProtocol = String(T.metaProtocol || '') - .replace(/{{USER_NAME}}/g, userName) - .replace(/{{CHAR_NAME}}/g, charName); + let metaProtocol = (isCommentary ? COMMENTARY_PROTOCOL : String(T.metaProtocol || '')).replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName); if (imgSettings?.enablePrompt) metaProtocol += `\n\n${IMG_GUIDELINE}`; if (voiceSettings?.enabled) metaProtocol += `\n\n${VOICE_GUIDELINE}`; @@ -745,19 +771,20 @@ async function buildCommentaryPrompt(targetText, type) { session.history || [], store.settings || {}, settings.fourthWallImage || {}, - settings.fourthWallVoice || {} + settings.fourthWallVoice || {}, + true ); let msg4; if (type === 'ai_message') { - msg4 = `现在剧本还在继续中,你刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。 -直接输出内容,30字以内。`; + msg4 = `现在剧本还在继续中,我刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。 +我将直接输出内容:`; } else if (type === 'edit_own') { - msg4 = `现在剧本还在继续中,我发现你刚才悄悄编辑了自己的台词:「${String(targetText || '')}」 -皮下吐槽一句(也可以稍微衔接之前的meta_history)。直接输出内容,30字以内。`; + msg4 = `现在剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}」 +必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出内容:`; } else if (type === 'edit_ai') { - msg4 = `现在剧本还在继续中,我发现你居然偷偷改了我的台词:「${String(targetText || '')}」 -皮下吐槽一下(也可以稍微衔接之前的meta_history)。直接输出内容,30字以内。`; + msg4 = `现在剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}」 +必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出内容:。`; } return { msg1, msg2, msg3, msg4 }; diff --git a/modules/iframe-renderer.js b/modules/iframe-renderer.js index 85f3213..96f775f 100644 --- a/modules/iframe-renderer.js +++ b/modules/iframe-renderer.js @@ -8,10 +8,10 @@ import { default_user_avatar, default_avatar } from "../../../../../script.js"; const MODULE_ID = 'iframeRenderer'; const events = createModuleEvents(MODULE_ID); - -let isGenerating = false; -const winMap = new Map(); -let lastHeights = new WeakMap(); + +let isGenerating = false; +const winMap = new Map(); +let lastHeights = new WeakMap(); const blobUrls = new WeakMap(); const hashToBlobUrl = new Map(); const hashToBlobBytes = new Map(); @@ -35,30 +35,30 @@ CacheRegistry.register(MODULE_ID, { }, getDetail: () => Array.from(hashToBlobUrl.keys()), }); - -function getSettings() { - return extension_settings[EXT_ID] || {}; -} - -function djb2(str) { - let h = 5381; - for (let i = 0; i < str.length; i++) { - h = ((h << 5) + h) ^ str.charCodeAt(i); - } - return (h >>> 0).toString(16); -} - -function shouldRenderContentByBlock(codeBlock) { - if (!codeBlock) return false; - const content = (codeBlock.textContent || '').trim().toLowerCase(); - if (!content) return false; - return content.includes('>> 0).toString(16); +} + +function shouldRenderContentByBlock(codeBlock) { + if (!codeBlock) return false; + const content = (codeBlock.textContent || '').trim().toLowerCase(); + if (!content) return false; + return content.includes(' { try { URL.revokeObjectURL(u); } catch {} }); @@ -97,672 +97,672 @@ export function clearBlobCaches() { hashToBlobBytes.clear(); blobLRU.length = 0; } - -function buildResourceHints(html) { - const urls = Array.from(new Set((html.match(/https?:\/\/[^"'()\s]+/gi) || []) - .map(u => { try { return new URL(u).origin; } catch { return null; } }) - .filter(Boolean))); - let hints = ""; - const maxHosts = 6; - for (let i = 0; i < Math.min(urls.length, maxHosts); i++) { - const origin = urls[i]; - hints += ``; - hints += ``; - } - let preload = ""; - const font = (html.match(/https?:\/\/[^"'()\s]+\.(?:woff2|woff|ttf|otf)/i) || [])[0]; - if (font) { - const type = font.endsWith(".woff2") ? "font/woff2" : font.endsWith(".woff") ? "font/woff" : font.endsWith(".ttf") ? "font/ttf" : "font/otf"; - preload += ``; - } - const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0]; - if (css) { - preload += ``; - } - const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0]; - if (img) { - preload += ``; - } - return hints + preload; -} - -function iframeClientScript() { - return ` -(function(){ - function measureVisibleHeight(){ - try{ - var doc = document; - var target = doc.body; - if(!target) return 0; - - var minTop = Infinity, maxBottom = 0; - var addRect = function(el){ - try{ - var r = el.getBoundingClientRect(); - if(r && r.height > 0){ - if(minTop > r.top) minTop = r.top; - if(maxBottom < r.bottom) maxBottom = r.bottom; - } - }catch(e){} - }; - - addRect(target); - var children = target.children || []; - for(var i=0;i 0 ? Math.ceil(maxBottom - Math.min(minTop, 0)) : (target.scrollHeight || 0); - }catch(e){ - return (document.body && document.body.scrollHeight) || 0; - } - } - - function post(m){ try{ parent.postMessage(m,'*') }catch(e){} } - - var rafPending=false, lastH=0; - var HYSTERESIS = 2; - - function send(force){ - if(rafPending && !force) return; - rafPending = true; - requestAnimationFrame(function(){ - rafPending = false; - var h = measureVisibleHeight(); - if(force || Math.abs(h - lastH) >= HYSTERESIS){ - lastH = h; - post({height:h, force:!!force}); - } - }); - } - - try{ send(true) }catch(e){} - document.addEventListener('DOMContentLoaded', function(){ send(true) }, {once:true}); - window.addEventListener('load', function(){ send(true) }, {once:true}); - - try{ - if(document.fonts){ - document.fonts.ready.then(function(){ send(true) }).catch(function(){}); - if(document.fonts.addEventListener){ - document.fonts.addEventListener('loadingdone', function(){ send(true) }); - document.fonts.addEventListener('loadingerror', function(){ send(true) }); - } - } - }catch(e){} - - ['transitionend','animationend'].forEach(function(evt){ - document.addEventListener(evt, function(){ send(false) }, {passive:true, capture:true}); - }); - - try{ - var root = document.body || document.documentElement; - var ro = new ResizeObserver(function(){ send(false) }); - ro.observe(root); - }catch(e){ - try{ - var rootMO = document.body || document.documentElement; - new MutationObserver(function(){ send(false) }) - .observe(rootMO, {childList:true, subtree:true, attributes:true, characterData:true}); - }catch(e){} - window.addEventListener('resize', function(){ send(false) }, {passive:true}); - } - - window.addEventListener('message', function(e){ - var d = e && e.data || {}; - if(d && d.type === 'probe') setTimeout(function(){ send(true) }, 10); - }); - - window.STscript = function(command){ - return new Promise(function(resolve,reject){ - try{ - if(!command){ reject(new Error('empty')); return } - if(command[0] !== '/') command = '/' + command; - var id = Date.now().toString(36) + Math.random().toString(36).slice(2); - function onMessage(e){ - var d = e && e.data || {}; - if(d.source !== 'xiaobaix-host') return; - if((d.type === 'commandResult' || d.type === 'commandError') && d.id === id){ - try{ window.removeEventListener('message', onMessage) }catch(e){} - if(d.type === 'commandResult') resolve(d.result); - else reject(new Error(d.error || 'error')); - } - } - try{ window.addEventListener('message', onMessage) }catch(e){} - post({type:'runCommand', id, command}); - setTimeout(function(){ - try{ window.removeEventListener('message', onMessage) }catch(e){} - reject(new Error('Command timeout')) - }, 180000); - }catch(e){ reject(e) } - }) - }; - try{ if(typeof window['stscript'] !== 'function') window['stscript'] = window.STscript }catch(e){} -})();`; -} - -function buildWrappedHtml(html) { - const settings = getSettings(); - const api = ``; - const wrapperToggle = settings.wrapperIframe ?? true; - const origin = typeof location !== 'undefined' && location.origin ? location.origin : ''; - const optWrapperUrl = `${origin}/scripts/extensions/third-party/${EXT_ID}/bridges/wrapper-iframe.js`; - const optWrapper = wrapperToggle ? `` : ""; - const baseTag = settings.useBlob ? `` : ""; - const headHints = buildResourceHints(html); - const vhFix = ``; - - if (html.includes('')) - return html.replace('', `${baseTag}${api}${optWrapper}${headHints}${vhFix}`); - if (html.includes('')) - return html.replace('', `${baseTag}${api}${optWrapper}${headHints}${vhFix}`); - return html.replace('${baseTag}${api}${optWrapper}${headHints}${vhFix} - - - - -${baseTag} -${api} -${optWrapper} -${headHints} -${vhFix} - - -${html}`; -} - -function getOrCreateWrapper(preEl) { - let wrapper = preEl.previousElementSibling; - if (!wrapper || !wrapper.classList.contains('xiaobaix-iframe-wrapper')) { - wrapper = document.createElement('div'); - wrapper.className = 'xiaobaix-iframe-wrapper'; - wrapper.style.cssText = 'margin:0;'; - preEl.parentNode.insertBefore(wrapper, preEl); - } - return wrapper; -} - -function registerIframeMapping(iframe, wrapper) { - const tryMap = () => { - try { - if (iframe && iframe.contentWindow) { - winMap.set(iframe.contentWindow, { iframe, wrapper }); - return true; - } - } catch (e) {} - return false; - }; - if (tryMap()) return; - let tries = 0; - const t = setInterval(() => { - tries++; - if (tryMap() || tries > 20) clearInterval(t); - }, 25); -} - -function resolveAvatarUrls() { - const origin = typeof location !== 'undefined' && location.origin ? location.origin : ''; - const toAbsUrl = (relOrUrl) => { - if (!relOrUrl) return ''; - const s = String(relOrUrl); - if (/^(data:|blob:|https?:)/i.test(s)) return s; - if (s.startsWith('User Avatars/')) { - return `${origin}/${s}`; - } - const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/'); - return `${origin}/${encoded.replace(/^\/+/, '')}`; - }; - const pickSrc = (selectors) => { - for (const sel of selectors) { - const el = document.querySelector(sel); - if (el) { - const highRes = el.getAttribute('data-izoomify-url'); - if (highRes) return highRes; - if (el.src) return el.src; - } - } - return ''; - }; - let user = pickSrc([ - '#user_avatar_block img', - '#avatar_user img', - '.user_avatar img', - 'img#avatar_user', - '.st-user-avatar img' - ]) || default_user_avatar; - const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i); - if (m) { - user = `User Avatars/${decodeURIComponent(m[1])}`; - } - const ctx = getContext?.() || {}; - const chId = ctx.characterId ?? ctx.this_chid; - const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null; - let char = ch?.avatar || default_avatar; - if (char && !/^(data:|blob:|https?:)/i.test(char)) { - char = /[\/]/.test(char) ? char.replace(/^\/+/, '') : `characters/${char}`; - } - return { user: toAbsUrl(user), char: toAbsUrl(char) }; -} - -function handleIframeMessage(event) { - const data = event.data || {}; - let rec = winMap.get(event.source); - - if (!rec || !rec.iframe) { - const iframes = document.querySelectorAll('iframe.xiaobaix-iframe'); - for (const iframe of iframes) { - if (iframe.contentWindow === event.source) { - rec = { iframe, wrapper: iframe.parentElement }; - winMap.set(event.source, rec); - break; - } - } - } - - if (rec && rec.iframe && typeof data.height === 'number') { - const next = Math.max(0, Number(data.height) || 0); - if (next < 1) return; - const prev = lastHeights.get(rec.iframe) || 0; - if (!data.force && Math.abs(next - prev) < 1) return; - if (data.force) { - lastHeights.set(rec.iframe, next); - requestAnimationFrame(() => { rec.iframe.style.height = `${next}px`; }); - return; - } - pendingHeight = next; - pendingRec = rec; - const now = performance.now(); - const dt = now - lastApplyTs; - if (dt >= 50) { - lastApplyTs = now; - const h = pendingHeight, r = pendingRec; - pendingHeight = null; - pendingRec = null; - lastHeights.set(r.iframe, h); - requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; }); - } else { - setTimeout(() => { - if (pendingRec && pendingHeight != null) { - lastApplyTs = performance.now(); - const h = pendingHeight, r = pendingRec; - pendingHeight = null; - pendingRec = null; - lastHeights.set(r.iframe, h); - requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; }); - } - }, Math.max(0, 50 - dt)); - } - return; - } - - if (data && data.type === 'runCommand') { - executeSlashCommand(data.command) - .then(result => event.source.postMessage({ - source: 'xiaobaix-host', - type: 'commandResult', - id: data.id, - result - }, '*')) - .catch(err => event.source.postMessage({ - source: 'xiaobaix-host', - type: 'commandError', - id: data.id, - error: err.message || String(err) - }, '*')); - return; - } - - if (data && data.type === 'getAvatars') { - try { - const urls = resolveAvatarUrls(); - event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls }, '*'); - } catch (e) { - event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls: { user: '', char: '' } }, '*'); - } - return; - } -} - -export function renderHtmlInIframe(htmlContent, container, preElement) { - const settings = getSettings(); - try { - const originalHash = djb2(htmlContent); - - if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') { - try { - htmlContent = replaceXbGetVarInString(htmlContent); - } catch (e) { - console.warn('xbgetvar 宏替换失败:', e); - } - } - - const iframe = document.createElement('iframe'); - iframe.id = generateUniqueId(); - iframe.className = 'xiaobaix-iframe'; - iframe.style.cssText = 'width:100%;border:none;background:transparent;overflow:hidden;height:0;margin:0;padding:0;display:block;contain:layout paint style;will-change:height;min-height:50px'; - iframe.setAttribute('frameborder', '0'); - iframe.setAttribute('scrolling', 'no'); - iframe.loading = 'eager'; - - if (settings.sandboxMode) { - iframe.setAttribute('sandbox', 'allow-scripts'); - } - - const wrapper = getOrCreateWrapper(preElement); - wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => { - try { old.src = 'about:blank'; } catch (e) {} - releaseIframeBlob(old); - old.remove(); - }); - - const codeHash = djb2(htmlContent); - const full = buildWrappedHtml(htmlContent); - - if (settings.useBlob) { - setIframeBlobHTML(iframe, full, codeHash); - } else { - iframe.srcdoc = full; - } - - wrapper.appendChild(iframe); - preElement.classList.remove('xb-show'); - preElement.style.display = 'none'; - registerIframeMapping(iframe, wrapper); - - try { iframe.contentWindow?.postMessage({ type: 'probe' }, '*'); } catch (e) {} - preElement.dataset.xbFinal = 'true'; - preElement.dataset.xbHash = originalHash; - - return iframe; - } catch (err) { - console.error('[iframeRenderer] 渲染失败:', err); - return null; - } -} - -export function processCodeBlocks(messageElement, forceFinal = true) { - const settings = getSettings(); - if (!settings.enabled) return; - if (settings.renderEnabled === false) return; - - try { - const codeBlocks = messageElement.querySelectorAll('pre > code'); - const ctx = getContext(); - const lastId = ctx.chat?.length - 1; - const mesEl = messageElement.closest('.mes'); - const mesId = mesEl ? Number(mesEl.getAttribute('mesid')) : null; - - if (isGenerating && mesId === lastId && !forceFinal) return; - - codeBlocks.forEach(codeBlock => { - const preElement = codeBlock.parentElement; - const should = shouldRenderContentByBlock(codeBlock); - const html = codeBlock.textContent || ''; - const hash = djb2(html); - const isFinal = preElement.dataset.xbFinal === 'true'; - const same = preElement.dataset.xbHash === hash; - - if (isFinal && same) return; - - if (should) { - renderHtmlInIframe(html, preElement.parentNode, preElement); - } else { - preElement.classList.add('xb-show'); - preElement.removeAttribute('data-xbfinal'); - preElement.removeAttribute('data-xbhash'); - preElement.style.display = ''; - } - preElement.dataset.xiaobaixBound = 'true'; - }); - } catch (err) { - console.error('[iframeRenderer] processCodeBlocks 失败:', err); - } -} - -export function processExistingMessages() { - const settings = getSettings(); - if (!settings.enabled) return; - document.querySelectorAll('.mes_text').forEach(el => processCodeBlocks(el, true)); - try { shrinkRenderedWindowFull(); } catch (e) {} -} - -export function processMessageById(messageId, forceFinal = true) { - const messageElement = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`); - if (!messageElement) return; - processCodeBlocks(messageElement, forceFinal); - try { shrinkRenderedWindowForLastMessage(); } catch (e) {} -} - -export function invalidateMessage(messageId) { - const el = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`); - if (!el) return; - el.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { - w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { - try { ifr.src = 'about:blank'; } catch (e) {} - releaseIframeBlob(ifr); - }); - w.remove(); - }); - el.querySelectorAll('pre').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - pre.removeAttribute('data-xbhash'); - delete pre.dataset.xbFinal; - delete pre.dataset.xbHash; - pre.style.display = ''; - delete pre.dataset.xiaobaixBound; - }); -} - -export function invalidateAll() { - document.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { - w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { - try { ifr.src = 'about:blank'; } catch (e) {} - releaseIframeBlob(ifr); - }); - w.remove(); - }); - document.querySelectorAll('.mes_text pre').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - pre.removeAttribute('data-xbhash'); - delete pre.dataset.xbFinal; - delete pre.dataset.xbHash; - delete pre.dataset.xiaobaixBound; - pre.style.display = ''; - }); - clearBlobCaches(); - winMap.clear(); - lastHeights = new WeakMap(); -} - -function shrinkRenderedWindowForLastMessage() { - const settings = getSettings(); - if (!settings.enabled) return; - if (settings.renderEnabled === false) return; - const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0 - ? settings.maxRenderedMessages - : 0; - if (max <= 0) return; - const ctx = getContext?.(); - const chatArr = ctx?.chat; - if (!Array.isArray(chatArr) || chatArr.length === 0) return; - const lastId = chatArr.length - 1; - if (lastId < 0) return; - const keepFrom = Math.max(0, lastId - max + 1); - const mesList = document.querySelectorAll('div.mes'); - for (const mes of mesList) { - const mesIdAttr = mes.getAttribute('mesid'); - if (mesIdAttr == null) continue; - const mesId = Number(mesIdAttr); - if (!Number.isFinite(mesId)) continue; - if (mesId >= keepFrom) break; - const mesText = mes.querySelector('.mes_text'); - if (!mesText) continue; - mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { - w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { - try { ifr.src = 'about:blank'; } catch (e) {} - releaseIframeBlob(ifr); - }); - w.remove(); - }); - mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - pre.removeAttribute('data-xbhash'); - delete pre.dataset.xbFinal; - delete pre.dataset.xbHash; - delete pre.dataset.xiaobaixBound; - pre.style.display = ''; - }); - } -} - -function shrinkRenderedWindowFull() { - const settings = getSettings(); - if (!settings.enabled) return; - if (settings.renderEnabled === false) return; - const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0 - ? settings.maxRenderedMessages - : 0; - if (max <= 0) return; - const ctx = getContext?.(); - const chatArr = ctx?.chat; - if (!Array.isArray(chatArr) || chatArr.length === 0) return; - const lastId = chatArr.length - 1; - const keepFrom = Math.max(0, lastId - max + 1); - const mesList = document.querySelectorAll('div.mes'); - for (const mes of mesList) { - const mesIdAttr = mes.getAttribute('mesid'); - if (mesIdAttr == null) continue; - const mesId = Number(mesIdAttr); - if (!Number.isFinite(mesId)) continue; - if (mesId >= keepFrom) continue; - const mesText = mes.querySelector('.mes_text'); - if (!mesText) continue; - mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { - w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { - try { ifr.src = 'about:blank'; } catch (e) {} - releaseIframeBlob(ifr); - }); - w.remove(); - }); - mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - pre.removeAttribute('data-xbhash'); - delete pre.dataset.xbFinal; - delete pre.dataset.xbHash; - delete pre.dataset.xiaobaixBound; - pre.style.display = ''; - }); - } -} - -let messageListenerBound = false; - + +function buildResourceHints(html) { + const urls = Array.from(new Set((html.match(/https?:\/\/[^"'()\s]+/gi) || []) + .map(u => { try { return new URL(u).origin; } catch { return null; } }) + .filter(Boolean))); + let hints = ""; + const maxHosts = 6; + for (let i = 0; i < Math.min(urls.length, maxHosts); i++) { + const origin = urls[i]; + hints += ``; + hints += ``; + } + let preload = ""; + const font = (html.match(/https?:\/\/[^"'()\s]+\.(?:woff2|woff|ttf|otf)/i) || [])[0]; + if (font) { + const type = font.endsWith(".woff2") ? "font/woff2" : font.endsWith(".woff") ? "font/woff" : font.endsWith(".ttf") ? "font/ttf" : "font/otf"; + preload += ``; + } + const css = (html.match(/https?:\/\/[^"'()\s]+\.css/i) || [])[0]; + if (css) { + preload += ``; + } + const img = (html.match(/https?:\/\/[^"'()\s]+\.(?:png|jpg|jpeg|webp|gif|svg)/i) || [])[0]; + if (img) { + preload += ``; + } + return hints + preload; +} + +function iframeClientScript() { + return ` +(function(){ + function measureVisibleHeight(){ + try{ + var doc = document; + var target = doc.body; + if(!target) return 0; + + var minTop = Infinity, maxBottom = 0; + var addRect = function(el){ + try{ + var r = el.getBoundingClientRect(); + if(r && r.height > 0){ + if(minTop > r.top) minTop = r.top; + if(maxBottom < r.bottom) maxBottom = r.bottom; + } + }catch(e){} + }; + + addRect(target); + var children = target.children || []; + for(var i=0;i 0 ? Math.ceil(maxBottom - Math.min(minTop, 0)) : (target.scrollHeight || 0); + }catch(e){ + return (document.body && document.body.scrollHeight) || 0; + } + } + + function post(m){ try{ parent.postMessage(m,'*') }catch(e){} } + + var rafPending=false, lastH=0; + var HYSTERESIS = 2; + + function send(force){ + if(rafPending && !force) return; + rafPending = true; + requestAnimationFrame(function(){ + rafPending = false; + var h = measureVisibleHeight(); + if(force || Math.abs(h - lastH) >= HYSTERESIS){ + lastH = h; + post({height:h, force:!!force}); + } + }); + } + + try{ send(true) }catch(e){} + document.addEventListener('DOMContentLoaded', function(){ send(true) }, {once:true}); + window.addEventListener('load', function(){ send(true) }, {once:true}); + + try{ + if(document.fonts){ + document.fonts.ready.then(function(){ send(true) }).catch(function(){}); + if(document.fonts.addEventListener){ + document.fonts.addEventListener('loadingdone', function(){ send(true) }); + document.fonts.addEventListener('loadingerror', function(){ send(true) }); + } + } + }catch(e){} + + ['transitionend','animationend'].forEach(function(evt){ + document.addEventListener(evt, function(){ send(false) }, {passive:true, capture:true}); + }); + + try{ + var root = document.body || document.documentElement; + var ro = new ResizeObserver(function(){ send(false) }); + ro.observe(root); + }catch(e){ + try{ + var rootMO = document.body || document.documentElement; + new MutationObserver(function(){ send(false) }) + .observe(rootMO, {childList:true, subtree:true, attributes:true, characterData:true}); + }catch(e){} + window.addEventListener('resize', function(){ send(false) }, {passive:true}); + } + + window.addEventListener('message', function(e){ + var d = e && e.data || {}; + if(d && d.type === 'probe') setTimeout(function(){ send(true) }, 10); + }); + + window.STscript = function(command){ + return new Promise(function(resolve,reject){ + try{ + if(!command){ reject(new Error('empty')); return } + if(command[0] !== '/') command = '/' + command; + var id = Date.now().toString(36) + Math.random().toString(36).slice(2); + function onMessage(e){ + var d = e && e.data || {}; + if(d.source !== 'xiaobaix-host') return; + if((d.type === 'commandResult' || d.type === 'commandError') && d.id === id){ + try{ window.removeEventListener('message', onMessage) }catch(e){} + if(d.type === 'commandResult') resolve(d.result); + else reject(new Error(d.error || 'error')); + } + } + try{ window.addEventListener('message', onMessage) }catch(e){} + post({type:'runCommand', id, command}); + setTimeout(function(){ + try{ window.removeEventListener('message', onMessage) }catch(e){} + reject(new Error('Command timeout')) + }, 180000); + }catch(e){ reject(e) } + }) + }; + try{ if(typeof window['stscript'] !== 'function') window['stscript'] = window.STscript }catch(e){} +})();`; +} + +function buildWrappedHtml(html) { + const settings = getSettings(); + const api = ``; + const wrapperToggle = settings.wrapperIframe ?? true; + const origin = typeof location !== 'undefined' && location.origin ? location.origin : ''; + const optWrapperUrl = `${origin}/scripts/extensions/third-party/${EXT_ID}/bridges/wrapper-iframe.js`; + const optWrapper = wrapperToggle ? `` : ""; + const baseTag = settings.useBlob ? `` : ""; + const headHints = buildResourceHints(html); + const vhFix = ``; + + if (html.includes('')) + return html.replace('', `${baseTag}${api}${optWrapper}${headHints}${vhFix}`); + if (html.includes('')) + return html.replace('', `${baseTag}${api}${optWrapper}${headHints}${vhFix}`); + return html.replace('${baseTag}${api}${optWrapper}${headHints}${vhFix} + + + + +${baseTag} +${api} +${optWrapper} +${headHints} +${vhFix} + + +${html}`; +} + +function getOrCreateWrapper(preEl) { + let wrapper = preEl.previousElementSibling; + if (!wrapper || !wrapper.classList.contains('xiaobaix-iframe-wrapper')) { + wrapper = document.createElement('div'); + wrapper.className = 'xiaobaix-iframe-wrapper'; + wrapper.style.cssText = 'margin:0;'; + preEl.parentNode.insertBefore(wrapper, preEl); + } + return wrapper; +} + +function registerIframeMapping(iframe, wrapper) { + const tryMap = () => { + try { + if (iframe && iframe.contentWindow) { + winMap.set(iframe.contentWindow, { iframe, wrapper }); + return true; + } + } catch (e) {} + return false; + }; + if (tryMap()) return; + let tries = 0; + const t = setInterval(() => { + tries++; + if (tryMap() || tries > 20) clearInterval(t); + }, 25); +} + +function resolveAvatarUrls() { + const origin = typeof location !== 'undefined' && location.origin ? location.origin : ''; + const toAbsUrl = (relOrUrl) => { + if (!relOrUrl) return ''; + const s = String(relOrUrl); + if (/^(data:|blob:|https?:)/i.test(s)) return s; + if (s.startsWith('User Avatars/')) { + return `${origin}/${s}`; + } + const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/'); + return `${origin}/${encoded.replace(/^\/+/, '')}`; + }; + const pickSrc = (selectors) => { + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + const highRes = el.getAttribute('data-izoomify-url'); + if (highRes) return highRes; + if (el.src) return el.src; + } + } + return ''; + }; + let user = pickSrc([ + '#user_avatar_block img', + '#avatar_user img', + '.user_avatar img', + 'img#avatar_user', + '.st-user-avatar img' + ]) || default_user_avatar; + const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i); + if (m) { + user = `User Avatars/${decodeURIComponent(m[1])}`; + } + const ctx = getContext?.() || {}; + const chId = ctx.characterId ?? ctx.this_chid; + const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null; + let char = ch?.avatar || default_avatar; + if (char && !/^(data:|blob:|https?:)/i.test(char)) { + char = /[\/]/.test(char) ? char.replace(/^\/+/, '') : `characters/${char}`; + } + return { user: toAbsUrl(user), char: toAbsUrl(char) }; +} + +function handleIframeMessage(event) { + const data = event.data || {}; + let rec = winMap.get(event.source); + + if (!rec || !rec.iframe) { + const iframes = document.querySelectorAll('iframe.xiaobaix-iframe'); + for (const iframe of iframes) { + if (iframe.contentWindow === event.source) { + rec = { iframe, wrapper: iframe.parentElement }; + winMap.set(event.source, rec); + break; + } + } + } + + if (rec && rec.iframe && typeof data.height === 'number') { + const next = Math.max(0, Number(data.height) || 0); + if (next < 1) return; + const prev = lastHeights.get(rec.iframe) || 0; + if (!data.force && Math.abs(next - prev) < 1) return; + if (data.force) { + lastHeights.set(rec.iframe, next); + requestAnimationFrame(() => { rec.iframe.style.height = `${next}px`; }); + return; + } + pendingHeight = next; + pendingRec = rec; + const now = performance.now(); + const dt = now - lastApplyTs; + if (dt >= 50) { + lastApplyTs = now; + const h = pendingHeight, r = pendingRec; + pendingHeight = null; + pendingRec = null; + lastHeights.set(r.iframe, h); + requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; }); + } else { + setTimeout(() => { + if (pendingRec && pendingHeight != null) { + lastApplyTs = performance.now(); + const h = pendingHeight, r = pendingRec; + pendingHeight = null; + pendingRec = null; + lastHeights.set(r.iframe, h); + requestAnimationFrame(() => { r.iframe.style.height = `${h}px`; }); + } + }, Math.max(0, 50 - dt)); + } + return; + } + + if (data && data.type === 'runCommand') { + executeSlashCommand(data.command) + .then(result => event.source.postMessage({ + source: 'xiaobaix-host', + type: 'commandResult', + id: data.id, + result + }, '*')) + .catch(err => event.source.postMessage({ + source: 'xiaobaix-host', + type: 'commandError', + id: data.id, + error: err.message || String(err) + }, '*')); + return; + } + + if (data && data.type === 'getAvatars') { + try { + const urls = resolveAvatarUrls(); + event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls }, '*'); + } catch (e) { + event.source?.postMessage({ source: 'xiaobaix-host', type: 'avatars', urls: { user: '', char: '' } }, '*'); + } + return; + } +} + +export function renderHtmlInIframe(htmlContent, container, preElement) { + const settings = getSettings(); + try { + const originalHash = djb2(htmlContent); + + if (settings.variablesCore?.enabled && typeof replaceXbGetVarInString === 'function') { + try { + htmlContent = replaceXbGetVarInString(htmlContent); + } catch (e) { + console.warn('xbgetvar 宏替换失败:', e); + } + } + + const iframe = document.createElement('iframe'); + iframe.id = generateUniqueId(); + iframe.className = 'xiaobaix-iframe'; + iframe.style.cssText = 'width:100%;border:none;background:transparent;overflow:hidden;height:0;margin:0;padding:0;display:block;contain:layout paint style;will-change:height;min-height:50px'; + iframe.setAttribute('frameborder', '0'); + iframe.setAttribute('scrolling', 'no'); + iframe.loading = 'eager'; + + if (settings.sandboxMode) { + iframe.setAttribute('sandbox', 'allow-scripts'); + } + + const wrapper = getOrCreateWrapper(preElement); + wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => { + try { old.src = 'about:blank'; } catch (e) {} + releaseIframeBlob(old); + old.remove(); + }); + + const codeHash = djb2(htmlContent); + const full = buildWrappedHtml(htmlContent); + + if (settings.useBlob) { + setIframeBlobHTML(iframe, full, codeHash); + } else { + iframe.srcdoc = full; + } + + wrapper.appendChild(iframe); + preElement.classList.remove('xb-show'); + preElement.style.display = 'none'; + registerIframeMapping(iframe, wrapper); + + try { iframe.contentWindow?.postMessage({ type: 'probe' }, '*'); } catch (e) {} + preElement.dataset.xbFinal = 'true'; + preElement.dataset.xbHash = originalHash; + + return iframe; + } catch (err) { + console.error('[iframeRenderer] 渲染失败:', err); + return null; + } +} + +export function processCodeBlocks(messageElement, forceFinal = true) { + const settings = getSettings(); + if (!settings.enabled) return; + if (settings.renderEnabled === false) return; + + try { + const codeBlocks = messageElement.querySelectorAll('pre > code'); + const ctx = getContext(); + const lastId = ctx.chat?.length - 1; + const mesEl = messageElement.closest('.mes'); + const mesId = mesEl ? Number(mesEl.getAttribute('mesid')) : null; + + if (isGenerating && mesId === lastId && !forceFinal) return; + + codeBlocks.forEach(codeBlock => { + const preElement = codeBlock.parentElement; + const should = shouldRenderContentByBlock(codeBlock); + const html = codeBlock.textContent || ''; + const hash = djb2(html); + const isFinal = preElement.dataset.xbFinal === 'true'; + const same = preElement.dataset.xbHash === hash; + + if (isFinal && same) return; + + if (should) { + renderHtmlInIframe(html, preElement.parentNode, preElement); + } else { + preElement.classList.add('xb-show'); + preElement.removeAttribute('data-xbfinal'); + preElement.removeAttribute('data-xbhash'); + preElement.style.display = ''; + } + preElement.dataset.xiaobaixBound = 'true'; + }); + } catch (err) { + console.error('[iframeRenderer] processCodeBlocks 失败:', err); + } +} + +export function processExistingMessages() { + const settings = getSettings(); + if (!settings.enabled) return; + document.querySelectorAll('.mes_text').forEach(el => processCodeBlocks(el, true)); + try { shrinkRenderedWindowFull(); } catch (e) {} +} + +export function processMessageById(messageId, forceFinal = true) { + const messageElement = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`); + if (!messageElement) return; + processCodeBlocks(messageElement, forceFinal); + try { shrinkRenderedWindowForLastMessage(); } catch (e) {} +} + +export function invalidateMessage(messageId) { + const el = document.querySelector(`div.mes[mesid="${messageId}"] .mes_text`); + if (!el) return; + el.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { + w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { + try { ifr.src = 'about:blank'; } catch (e) {} + releaseIframeBlob(ifr); + }); + w.remove(); + }); + el.querySelectorAll('pre').forEach(pre => { + pre.classList.remove('xb-show'); + pre.removeAttribute('data-xbfinal'); + pre.removeAttribute('data-xbhash'); + delete pre.dataset.xbFinal; + delete pre.dataset.xbHash; + pre.style.display = ''; + delete pre.dataset.xiaobaixBound; + }); +} + +export function invalidateAll() { + document.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { + w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { + try { ifr.src = 'about:blank'; } catch (e) {} + releaseIframeBlob(ifr); + }); + w.remove(); + }); + document.querySelectorAll('.mes_text pre').forEach(pre => { + pre.classList.remove('xb-show'); + pre.removeAttribute('data-xbfinal'); + pre.removeAttribute('data-xbhash'); + delete pre.dataset.xbFinal; + delete pre.dataset.xbHash; + delete pre.dataset.xiaobaixBound; + pre.style.display = ''; + }); + clearBlobCaches(); + winMap.clear(); + lastHeights = new WeakMap(); +} + +function shrinkRenderedWindowForLastMessage() { + const settings = getSettings(); + if (!settings.enabled) return; + if (settings.renderEnabled === false) return; + const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0 + ? settings.maxRenderedMessages + : 0; + if (max <= 0) return; + const ctx = getContext?.(); + const chatArr = ctx?.chat; + if (!Array.isArray(chatArr) || chatArr.length === 0) return; + const lastId = chatArr.length - 1; + if (lastId < 0) return; + const keepFrom = Math.max(0, lastId - max + 1); + const mesList = document.querySelectorAll('div.mes'); + for (const mes of mesList) { + const mesIdAttr = mes.getAttribute('mesid'); + if (mesIdAttr == null) continue; + const mesId = Number(mesIdAttr); + if (!Number.isFinite(mesId)) continue; + if (mesId >= keepFrom) break; + const mesText = mes.querySelector('.mes_text'); + if (!mesText) continue; + mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { + w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { + try { ifr.src = 'about:blank'; } catch (e) {} + releaseIframeBlob(ifr); + }); + w.remove(); + }); + mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { + pre.classList.remove('xb-show'); + pre.removeAttribute('data-xbfinal'); + pre.removeAttribute('data-xbhash'); + delete pre.dataset.xbFinal; + delete pre.dataset.xbHash; + delete pre.dataset.xiaobaixBound; + pre.style.display = ''; + }); + } +} + +function shrinkRenderedWindowFull() { + const settings = getSettings(); + if (!settings.enabled) return; + if (settings.renderEnabled === false) return; + const max = Number.isFinite(settings.maxRenderedMessages) && settings.maxRenderedMessages > 0 + ? settings.maxRenderedMessages + : 0; + if (max <= 0) return; + const ctx = getContext?.(); + const chatArr = ctx?.chat; + if (!Array.isArray(chatArr) || chatArr.length === 0) return; + const lastId = chatArr.length - 1; + const keepFrom = Math.max(0, lastId - max + 1); + const mesList = document.querySelectorAll('div.mes'); + for (const mes of mesList) { + const mesIdAttr = mes.getAttribute('mesid'); + if (mesIdAttr == null) continue; + const mesId = Number(mesIdAttr); + if (!Number.isFinite(mesId)) continue; + if (mesId >= keepFrom) continue; + const mesText = mes.querySelector('.mes_text'); + if (!mesText) continue; + mesText.querySelectorAll('.xiaobaix-iframe-wrapper').forEach(w => { + w.querySelectorAll('.xiaobaix-iframe').forEach(ifr => { + try { ifr.src = 'about:blank'; } catch (e) {} + releaseIframeBlob(ifr); + }); + w.remove(); + }); + mesText.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { + pre.classList.remove('xb-show'); + pre.removeAttribute('data-xbfinal'); + pre.removeAttribute('data-xbhash'); + delete pre.dataset.xbFinal; + delete pre.dataset.xbHash; + delete pre.dataset.xiaobaixBound; + pre.style.display = ''; + }); + } +} + +let messageListenerBound = false; + export function initRenderer() { try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {} events.on(event_types.GENERATION_STARTED, () => { isGenerating = true; }); - - events.on(event_types.GENERATION_ENDED, () => { - isGenerating = false; - const ctx = getContext(); - const lastId = ctx.chat?.length - 1; - if (lastId != null && lastId >= 0) { - setTimeout(() => { - processMessageById(lastId, true); - }, 60); - } - }); - - events.on(event_types.MESSAGE_RECEIVED, (data) => { - setTimeout(() => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }, 300); - }); - - events.on(event_types.MESSAGE_UPDATED, (data) => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }); - - events.on(event_types.MESSAGE_EDITED, (data) => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }); - - events.on(event_types.MESSAGE_DELETED, (data) => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - invalidateMessage(messageId); - } - }); - - events.on(event_types.MESSAGE_SWIPED, (data) => { - setTimeout(() => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }, 10); - }); - - events.on(event_types.USER_MESSAGE_RENDERED, (data) => { - setTimeout(() => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }, 10); - }); - - events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => { - setTimeout(() => { - const messageId = typeof data === 'object' ? data.messageId : data; - if (messageId != null) { - processMessageById(messageId, true); - } - }, 10); - }); - - events.on(event_types.CHAT_CHANGED, () => { - isGenerating = false; - invalidateAll(); - setTimeout(() => { - processExistingMessages(); - }, 100); - }); - - if (!messageListenerBound) { - window.addEventListener('message', handleIframeMessage); - messageListenerBound = true; - } - - setTimeout(processExistingMessages, 100); -} - + + events.on(event_types.GENERATION_ENDED, () => { + isGenerating = false; + const ctx = getContext(); + const lastId = ctx.chat?.length - 1; + if (lastId != null && lastId >= 0) { + setTimeout(() => { + processMessageById(lastId, true); + }, 60); + } + }); + + events.on(event_types.MESSAGE_RECEIVED, (data) => { + setTimeout(() => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }, 300); + }); + + events.on(event_types.MESSAGE_UPDATED, (data) => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }); + + events.on(event_types.MESSAGE_EDITED, (data) => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }); + + events.on(event_types.MESSAGE_DELETED, (data) => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + invalidateMessage(messageId); + } + }); + + events.on(event_types.MESSAGE_SWIPED, (data) => { + setTimeout(() => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }, 10); + }); + + events.on(event_types.USER_MESSAGE_RENDERED, (data) => { + setTimeout(() => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }, 10); + }); + + events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => { + setTimeout(() => { + const messageId = typeof data === 'object' ? data.messageId : data; + if (messageId != null) { + processMessageById(messageId, true); + } + }, 10); + }); + + events.on(event_types.CHAT_CHANGED, () => { + isGenerating = false; + invalidateAll(); + setTimeout(() => { + processExistingMessages(); + }, 100); + }); + + if (!messageListenerBound) { + window.addEventListener('message', handleIframeMessage); + messageListenerBound = true; + } + + setTimeout(processExistingMessages, 100); +} + export function cleanupRenderer() { try { xbLog.info(MODULE_ID, 'cleanupRenderer'); } catch {} events.cleanup(); @@ -770,15 +770,15 @@ export function cleanupRenderer() { window.removeEventListener('message', handleIframeMessage); messageListenerBound = false; } - invalidateAll(); - isGenerating = false; - pendingHeight = null; - pendingRec = null; - lastApplyTs = 0; -} - -export function isCurrentlyGenerating() { - return isGenerating; -} - -export { shrinkRenderedWindowFull, shrinkRenderedWindowForLastMessage }; + invalidateAll(); + isGenerating = false; + pendingHeight = null; + pendingRec = null; + lastApplyTs = 0; +} + +export function isCurrentlyGenerating() { + return isGenerating; +} + +export { shrinkRenderedWindowFull, shrinkRenderedWindowForLastMessage }; diff --git a/modules/immersive-mode.js b/modules/immersive-mode.js index e27bffa..cbbaec1 100644 --- a/modules/immersive-mode.js +++ b/modules/immersive-mode.js @@ -1,473 +1,473 @@ -import { extension_settings, getContext } from "../../../../extensions.js"; -import { saveSettingsDebounced, this_chid, getCurrentChatId } from "../../../../../script.js"; -import { selected_group } from "../../../../group-chats.js"; -import { EXT_ID } from "../core/constants.js"; -import { createModuleEvents, event_types } from "../core/event-manager.js"; - -const defaultSettings = { - enabled: false, - showAllMessages: false, - autoJumpOnAI: true -}; - -const SEL = { - chat: '#chat', - mes: '#chat .mes', - ai: '#chat .mes[is_user="false"][is_system="false"]', - user: '#chat .mes[is_user="true"]' -}; - -const baseEvents = createModuleEvents('immersiveMode'); -const messageEvents = createModuleEvents('immersiveMode:messages'); - -let state = { - isActive: false, - eventsBound: false, - messageEventsBound: false, - globalStateHandler: null -}; - -let observer = null; -let resizeObs = null; -let resizeObservedEl = null; -let recalcT = null; - -const isGlobalEnabled = () => window.isXiaobaixEnabled ?? true; -const getSettings = () => extension_settings[EXT_ID].immersive; -const isInChat = () => this_chid !== undefined || selected_group || getCurrentChatId() !== undefined; - -function initImmersiveMode() { - initSettings(); - setupEventListeners(); - if (isGlobalEnabled()) { - state.isActive = getSettings().enabled; - if (state.isActive) enableImmersiveMode(); - bindSettingsEvents(); - } -} - -function initSettings() { - extension_settings[EXT_ID] ||= {}; - extension_settings[EXT_ID].immersive ||= structuredClone(defaultSettings); - const settings = extension_settings[EXT_ID].immersive; - Object.keys(defaultSettings).forEach(k => settings[k] = settings[k] ?? defaultSettings[k]); - updateControlState(); -} - -function setupEventListeners() { - state.globalStateHandler = handleGlobalStateChange; - baseEvents.on(event_types.CHAT_CHANGED, onChatChanged); - document.addEventListener('xiaobaixEnabledChanged', state.globalStateHandler); - if (window.registerModuleCleanup) window.registerModuleCleanup('immersiveMode', cleanup); -} - -function setupDOMObserver() { - if (observer) return; - const chatContainer = document.getElementById('chat'); - if (!chatContainer) return; - - observer = new MutationObserver((mutations) => { - if (!state.isActive) return; - let hasNewAI = false; - - for (const mutation of mutations) { - if (mutation.type === 'childList' && mutation.addedNodes?.length) { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === 1 && node.classList?.contains('mes')) { - processSingleMessage(node); - if (node.getAttribute('is_user') === 'false' && node.getAttribute('is_system') === 'false') { - hasNewAI = true; - } - } - }); - } - } - - if (hasNewAI) { - if (recalcT) clearTimeout(recalcT); - recalcT = setTimeout(updateMessageDisplay, 20); - } - }); - - observer.observe(chatContainer, { childList: true, subtree: true, characterData: true }); -} - -function processSingleMessage(mesElement) { - const $mes = $(mesElement); - const $avatarWrapper = $mes.find('.mesAvatarWrapper'); - const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween'); - const $targetSibling = $chName.find('.flex-container.flex1.alignitemscenter'); - const $nameText = $mes.find('.name_text'); - - if ($avatarWrapper.length && $chName.length && $targetSibling.length && - !$chName.find('.mesAvatarWrapper').length) { - $targetSibling.before($avatarWrapper); - - if ($nameText.length && !$nameText.parent().hasClass('xiaobaix-vertical-wrapper')) { - const $verticalWrapper = $('
'); - const $topGroup = $('
'); - $topGroup.append($nameText.detach(), $targetSibling.detach()); - $verticalWrapper.append($topGroup); - $avatarWrapper.after($verticalWrapper); - } - } -} - -function updateControlState() { - const enabled = isGlobalEnabled(); - $('#xiaobaix_immersive_enabled').prop('disabled', !enabled).toggleClass('disabled-control', !enabled); -} - -function bindSettingsEvents() { - if (state.eventsBound) return; - setTimeout(() => { - const checkbox = document.getElementById('xiaobaix_immersive_enabled'); - if (checkbox && !state.eventsBound) { - checkbox.checked = getSettings().enabled; - checkbox.addEventListener('change', () => setImmersiveMode(checkbox.checked)); - state.eventsBound = true; - } - }, 500); -} - -function unbindSettingsEvents() { - const checkbox = document.getElementById('xiaobaix_immersive_enabled'); - if (checkbox) { - const newCheckbox = checkbox.cloneNode(true); - checkbox.parentNode.replaceChild(newCheckbox, checkbox); - } - state.eventsBound = false; -} - -function setImmersiveMode(enabled) { - const settings = getSettings(); - settings.enabled = enabled; - state.isActive = enabled; - - const checkbox = document.getElementById('xiaobaix_immersive_enabled'); - if (checkbox) checkbox.checked = enabled; - - enabled ? enableImmersiveMode() : disableImmersiveMode(); - if (!enabled) cleanup(); - saveSettingsDebounced(); -} - -function toggleImmersiveMode() { - if (!isGlobalEnabled()) return; - setImmersiveMode(!getSettings().enabled); -} - -function bindMessageEvents() { - if (state.messageEventsBound) return; - - const refreshOnAI = () => state.isActive && updateMessageDisplay(); - - messageEvents.on(event_types.MESSAGE_SENT, () => {}); - messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI); - messageEvents.on(event_types.MESSAGE_DELETED, () => {}); - messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI); - messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI); - if (event_types.GENERATION_STARTED) { - messageEvents.on(event_types.GENERATION_STARTED, () => {}); - } - messageEvents.on(event_types.GENERATION_ENDED, refreshOnAI); - - state.messageEventsBound = true; -} - -function unbindMessageEvents() { - if (!state.messageEventsBound) return; - messageEvents.cleanup(); - state.messageEventsBound = false; -} - -function injectImmersiveStyles() { - let style = document.getElementById('immersive-style-tag'); - if (!style) { - style = document.createElement('style'); - style.id = 'immersive-style-tag'; - document.head.appendChild(style); - } - style.textContent = ` - body.immersive-mode.immersive-single #show_more_messages { display: none !important; } - `; -} - -function applyModeClasses() { - const settings = getSettings(); - $('body') - .toggleClass('immersive-single', !settings.showAllMessages) - .toggleClass('immersive-all', settings.showAllMessages); -} - -function enableImmersiveMode() { - if (!isGlobalEnabled()) return; - - injectImmersiveStyles(); - $('body').addClass('immersive-mode'); - applyModeClasses(); - moveAvatarWrappers(); - bindMessageEvents(); - updateMessageDisplay(); - setupDOMObserver(); -} - -function disableImmersiveMode() { - $('body').removeClass('immersive-mode immersive-single immersive-all'); - restoreAvatarWrappers(); - $(SEL.mes).show(); - hideNavigationButtons(); - $('.swipe_left, .swipeRightBlock').show(); - unbindMessageEvents(); - detachResizeObserver(); - destroyDOMObserver(); -} - -function moveAvatarWrappers() { - $(SEL.mes).each(function() { processSingleMessage(this); }); -} - -function restoreAvatarWrappers() { - $(SEL.mes).each(function() { - const $mes = $(this); - const $avatarWrapper = $mes.find('.mesAvatarWrapper'); - const $verticalWrapper = $mes.find('.xiaobaix-vertical-wrapper'); - - if ($avatarWrapper.length && !$avatarWrapper.parent().hasClass('mes')) { - $mes.prepend($avatarWrapper); - } - - if ($verticalWrapper.length) { - const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween'); - const $flexContainer = $mes.find('.flex-container.flex1.alignitemscenter'); - const $nameText = $mes.find('.name_text'); - - if ($flexContainer.length && $chName.length) $chName.prepend($flexContainer); - if ($nameText.length) { - const $originalContainer = $mes.find('.flex-container.alignItemsBaseline'); - if ($originalContainer.length) $originalContainer.prepend($nameText); - } - $verticalWrapper.remove(); - } - }); -} - -function findLastAIMessage() { - const $aiMessages = $(SEL.ai); - return $aiMessages.length ? $($aiMessages.last()) : null; -} - -function showSingleModeMessages() { - const $messages = $(SEL.mes); - if (!$messages.length) return; - - $messages.hide(); - - const $targetAI = findLastAIMessage(); - if ($targetAI?.length) { - $targetAI.show(); - - const $prevUser = $targetAI.prevAll('.mes[is_user="true"]').first(); - if ($prevUser.length) { - $prevUser.show(); - } - - $targetAI.nextAll('.mes').show(); - - addNavigationToLastTwoMessages(); - } -} - -function addNavigationToLastTwoMessages() { - hideNavigationButtons(); - - const $visibleMessages = $(`${SEL.mes}:visible`); - const messageCount = $visibleMessages.length; - - if (messageCount >= 2) { - const $lastTwo = $visibleMessages.slice(-2); - $lastTwo.each(function() { - showNavigationButtons($(this)); - updateSwipesCounter($(this)); - }); - } else if (messageCount === 1) { - const $single = $visibleMessages.last(); - showNavigationButtons($single); - updateSwipesCounter($single); - } -} - -function updateMessageDisplay() { - if (!state.isActive) return; - - const $messages = $(SEL.mes); - if (!$messages.length) return; - - const settings = getSettings(); - if (settings.showAllMessages) { - $messages.show(); - addNavigationToLastTwoMessages(); - } else { - showSingleModeMessages(); - } -} - -function showNavigationButtons($targetMes) { - if (!isInChat()) return; - - $targetMes.find('.immersive-navigation').remove(); - - const $verticalWrapper = $targetMes.find('.xiaobaix-vertical-wrapper'); - if (!$verticalWrapper.length) return; - - const settings = getSettings(); - const buttonText = settings.showAllMessages ? '切换:锁定单回合' : '切换:传统多楼层'; - const navigationHtml = ` -
- - - -
- `; - - $verticalWrapper.append(navigationHtml); - - $targetMes.find('.immersive-swipe-left').on('click', () => handleSwipe('.swipe_left', $targetMes)); - $targetMes.find('.immersive-toggle').on('click', toggleDisplayMode); - $targetMes.find('.immersive-swipe-right').on('click', () => handleSwipe('.swipe_right', $targetMes)); -} - -const hideNavigationButtons = () => $('.immersive-navigation').remove(); - -function updateSwipesCounter($targetMes) { - if (!state.isActive) return; - - const $swipesCounter = $targetMes.find('.swipes-counter'); - if (!$swipesCounter.length) return; - - const mesId = $targetMes.attr('mesid'); - - if (mesId !== undefined) { - try { - const chat = getContext().chat; - const mesIndex = parseInt(mesId); - const message = chat?.[mesIndex]; - if (message?.swipes) { - const currentSwipeIndex = message.swipe_id || 0; - $swipesCounter.html(`${currentSwipeIndex + 1}​/​${message.swipes.length}`); - return; - } - } catch {} - } - $swipesCounter.html('1​/​1'); -} - -function toggleDisplayMode() { - if (!state.isActive) return; - - const settings = getSettings(); - settings.showAllMessages = !settings.showAllMessages; - applyModeClasses(); - updateMessageDisplay(); - saveSettingsDebounced(); -} - -function handleSwipe(swipeSelector, $targetMes) { - if (!state.isActive) return; - - const $btn = $targetMes.find(swipeSelector); - if ($btn.length) { - $btn.click(); - setTimeout(() => { - updateSwipesCounter($targetMes); - }, 100); - } -} - -function handleGlobalStateChange(event) { - const enabled = event.detail.enabled; - updateControlState(); - - if (enabled) { - const settings = getSettings(); - state.isActive = settings.enabled; - if (state.isActive) enableImmersiveMode(); - bindSettingsEvents(); - setTimeout(() => { - const checkbox = document.getElementById('xiaobaix_immersive_enabled'); - if (checkbox) checkbox.checked = settings.enabled; - }, 100); - } else { - if (state.isActive) disableImmersiveMode(); - state.isActive = false; - unbindSettingsEvents(); - } -} - -function onChatChanged() { - if (!isGlobalEnabled() || !state.isActive) return; - - setTimeout(() => { - moveAvatarWrappers(); - updateMessageDisplay(); - }, 100); -} - -function cleanup() { - if (state.isActive) disableImmersiveMode(); - destroyDOMObserver(); - - baseEvents.cleanup(); - - if (state.globalStateHandler) { - document.removeEventListener('xiaobaixEnabledChanged', state.globalStateHandler); - } - - unbindMessageEvents(); - detachResizeObserver(); - - state = { - isActive: false, - eventsBound: false, - messageEventsBound: false, - globalStateHandler: null - }; -} - -function attachResizeObserverTo(el) { - if (!el) return; - - if (!resizeObs) { - resizeObs = new ResizeObserver(() => {}); - } - - if (resizeObservedEl) detachResizeObserver(); - resizeObservedEl = el; - resizeObs.observe(el); -} - -function detachResizeObserver() { - if (resizeObs && resizeObservedEl) { - resizeObs.unobserve(resizeObservedEl); - } - resizeObservedEl = null; -} - -function destroyDOMObserver() { - if (observer) { - observer.disconnect(); - observer = null; - } -} - -export { initImmersiveMode, toggleImmersiveMode }; +import { extension_settings, getContext } from "../../../../extensions.js"; +import { saveSettingsDebounced, this_chid, getCurrentChatId } from "../../../../../script.js"; +import { selected_group } from "../../../../group-chats.js"; +import { EXT_ID } from "../core/constants.js"; +import { createModuleEvents, event_types } from "../core/event-manager.js"; + +const defaultSettings = { + enabled: false, + showAllMessages: false, + autoJumpOnAI: true +}; + +const SEL = { + chat: '#chat', + mes: '#chat .mes', + ai: '#chat .mes[is_user="false"][is_system="false"]', + user: '#chat .mes[is_user="true"]' +}; + +const baseEvents = createModuleEvents('immersiveMode'); +const messageEvents = createModuleEvents('immersiveMode:messages'); + +let state = { + isActive: false, + eventsBound: false, + messageEventsBound: false, + globalStateHandler: null +}; + +let observer = null; +let resizeObs = null; +let resizeObservedEl = null; +let recalcT = null; + +const isGlobalEnabled = () => window.isXiaobaixEnabled ?? true; +const getSettings = () => extension_settings[EXT_ID].immersive; +const isInChat = () => this_chid !== undefined || selected_group || getCurrentChatId() !== undefined; + +function initImmersiveMode() { + initSettings(); + setupEventListeners(); + if (isGlobalEnabled()) { + state.isActive = getSettings().enabled; + if (state.isActive) enableImmersiveMode(); + bindSettingsEvents(); + } +} + +function initSettings() { + extension_settings[EXT_ID] ||= {}; + extension_settings[EXT_ID].immersive ||= structuredClone(defaultSettings); + const settings = extension_settings[EXT_ID].immersive; + Object.keys(defaultSettings).forEach(k => settings[k] = settings[k] ?? defaultSettings[k]); + updateControlState(); +} + +function setupEventListeners() { + state.globalStateHandler = handleGlobalStateChange; + baseEvents.on(event_types.CHAT_CHANGED, onChatChanged); + document.addEventListener('xiaobaixEnabledChanged', state.globalStateHandler); + if (window.registerModuleCleanup) window.registerModuleCleanup('immersiveMode', cleanup); +} + +function setupDOMObserver() { + if (observer) return; + const chatContainer = document.getElementById('chat'); + if (!chatContainer) return; + + observer = new MutationObserver((mutations) => { + if (!state.isActive) return; + let hasNewAI = false; + + for (const mutation of mutations) { + if (mutation.type === 'childList' && mutation.addedNodes?.length) { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === 1 && node.classList?.contains('mes')) { + processSingleMessage(node); + if (node.getAttribute('is_user') === 'false' && node.getAttribute('is_system') === 'false') { + hasNewAI = true; + } + } + }); + } + } + + if (hasNewAI) { + if (recalcT) clearTimeout(recalcT); + recalcT = setTimeout(updateMessageDisplay, 20); + } + }); + + observer.observe(chatContainer, { childList: true, subtree: true, characterData: true }); +} + +function processSingleMessage(mesElement) { + const $mes = $(mesElement); + const $avatarWrapper = $mes.find('.mesAvatarWrapper'); + const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween'); + const $targetSibling = $chName.find('.flex-container.flex1.alignitemscenter'); + const $nameText = $mes.find('.name_text'); + + if ($avatarWrapper.length && $chName.length && $targetSibling.length && + !$chName.find('.mesAvatarWrapper').length) { + $targetSibling.before($avatarWrapper); + + if ($nameText.length && !$nameText.parent().hasClass('xiaobaix-vertical-wrapper')) { + const $verticalWrapper = $('
'); + const $topGroup = $('
'); + $topGroup.append($nameText.detach(), $targetSibling.detach()); + $verticalWrapper.append($topGroup); + $avatarWrapper.after($verticalWrapper); + } + } +} + +function updateControlState() { + const enabled = isGlobalEnabled(); + $('#xiaobaix_immersive_enabled').prop('disabled', !enabled).toggleClass('disabled-control', !enabled); +} + +function bindSettingsEvents() { + if (state.eventsBound) return; + setTimeout(() => { + const checkbox = document.getElementById('xiaobaix_immersive_enabled'); + if (checkbox && !state.eventsBound) { + checkbox.checked = getSettings().enabled; + checkbox.addEventListener('change', () => setImmersiveMode(checkbox.checked)); + state.eventsBound = true; + } + }, 500); +} + +function unbindSettingsEvents() { + const checkbox = document.getElementById('xiaobaix_immersive_enabled'); + if (checkbox) { + const newCheckbox = checkbox.cloneNode(true); + checkbox.parentNode.replaceChild(newCheckbox, checkbox); + } + state.eventsBound = false; +} + +function setImmersiveMode(enabled) { + const settings = getSettings(); + settings.enabled = enabled; + state.isActive = enabled; + + const checkbox = document.getElementById('xiaobaix_immersive_enabled'); + if (checkbox) checkbox.checked = enabled; + + enabled ? enableImmersiveMode() : disableImmersiveMode(); + if (!enabled) cleanup(); + saveSettingsDebounced(); +} + +function toggleImmersiveMode() { + if (!isGlobalEnabled()) return; + setImmersiveMode(!getSettings().enabled); +} + +function bindMessageEvents() { + if (state.messageEventsBound) return; + + const refreshOnAI = () => state.isActive && updateMessageDisplay(); + + messageEvents.on(event_types.MESSAGE_SENT, () => {}); + messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI); + messageEvents.on(event_types.MESSAGE_DELETED, () => {}); + messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI); + messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI); + if (event_types.GENERATION_STARTED) { + messageEvents.on(event_types.GENERATION_STARTED, () => {}); + } + messageEvents.on(event_types.GENERATION_ENDED, refreshOnAI); + + state.messageEventsBound = true; +} + +function unbindMessageEvents() { + if (!state.messageEventsBound) return; + messageEvents.cleanup(); + state.messageEventsBound = false; +} + +function injectImmersiveStyles() { + let style = document.getElementById('immersive-style-tag'); + if (!style) { + style = document.createElement('style'); + style.id = 'immersive-style-tag'; + document.head.appendChild(style); + } + style.textContent = ` + body.immersive-mode.immersive-single #show_more_messages { display: none !important; } + `; +} + +function applyModeClasses() { + const settings = getSettings(); + $('body') + .toggleClass('immersive-single', !settings.showAllMessages) + .toggleClass('immersive-all', settings.showAllMessages); +} + +function enableImmersiveMode() { + if (!isGlobalEnabled()) return; + + injectImmersiveStyles(); + $('body').addClass('immersive-mode'); + applyModeClasses(); + moveAvatarWrappers(); + bindMessageEvents(); + updateMessageDisplay(); + setupDOMObserver(); +} + +function disableImmersiveMode() { + $('body').removeClass('immersive-mode immersive-single immersive-all'); + restoreAvatarWrappers(); + $(SEL.mes).show(); + hideNavigationButtons(); + $('.swipe_left, .swipeRightBlock').show(); + unbindMessageEvents(); + detachResizeObserver(); + destroyDOMObserver(); +} + +function moveAvatarWrappers() { + $(SEL.mes).each(function() { processSingleMessage(this); }); +} + +function restoreAvatarWrappers() { + $(SEL.mes).each(function() { + const $mes = $(this); + const $avatarWrapper = $mes.find('.mesAvatarWrapper'); + const $verticalWrapper = $mes.find('.xiaobaix-vertical-wrapper'); + + if ($avatarWrapper.length && !$avatarWrapper.parent().hasClass('mes')) { + $mes.prepend($avatarWrapper); + } + + if ($verticalWrapper.length) { + const $chName = $mes.find('.ch_name.flex-container.justifySpaceBetween'); + const $flexContainer = $mes.find('.flex-container.flex1.alignitemscenter'); + const $nameText = $mes.find('.name_text'); + + if ($flexContainer.length && $chName.length) $chName.prepend($flexContainer); + if ($nameText.length) { + const $originalContainer = $mes.find('.flex-container.alignItemsBaseline'); + if ($originalContainer.length) $originalContainer.prepend($nameText); + } + $verticalWrapper.remove(); + } + }); +} + +function findLastAIMessage() { + const $aiMessages = $(SEL.ai); + return $aiMessages.length ? $($aiMessages.last()) : null; +} + +function showSingleModeMessages() { + const $messages = $(SEL.mes); + if (!$messages.length) return; + + $messages.hide(); + + const $targetAI = findLastAIMessage(); + if ($targetAI?.length) { + $targetAI.show(); + + const $prevUser = $targetAI.prevAll('.mes[is_user="true"]').first(); + if ($prevUser.length) { + $prevUser.show(); + } + + $targetAI.nextAll('.mes').show(); + + addNavigationToLastTwoMessages(); + } +} + +function addNavigationToLastTwoMessages() { + hideNavigationButtons(); + + const $visibleMessages = $(`${SEL.mes}:visible`); + const messageCount = $visibleMessages.length; + + if (messageCount >= 2) { + const $lastTwo = $visibleMessages.slice(-2); + $lastTwo.each(function() { + showNavigationButtons($(this)); + updateSwipesCounter($(this)); + }); + } else if (messageCount === 1) { + const $single = $visibleMessages.last(); + showNavigationButtons($single); + updateSwipesCounter($single); + } +} + +function updateMessageDisplay() { + if (!state.isActive) return; + + const $messages = $(SEL.mes); + if (!$messages.length) return; + + const settings = getSettings(); + if (settings.showAllMessages) { + $messages.show(); + addNavigationToLastTwoMessages(); + } else { + showSingleModeMessages(); + } +} + +function showNavigationButtons($targetMes) { + if (!isInChat()) return; + + $targetMes.find('.immersive-navigation').remove(); + + const $verticalWrapper = $targetMes.find('.xiaobaix-vertical-wrapper'); + if (!$verticalWrapper.length) return; + + const settings = getSettings(); + const buttonText = settings.showAllMessages ? '切换:锁定单回合' : '切换:传统多楼层'; + const navigationHtml = ` +
+ + + +
+ `; + + $verticalWrapper.append(navigationHtml); + + $targetMes.find('.immersive-swipe-left').on('click', () => handleSwipe('.swipe_left', $targetMes)); + $targetMes.find('.immersive-toggle').on('click', toggleDisplayMode); + $targetMes.find('.immersive-swipe-right').on('click', () => handleSwipe('.swipe_right', $targetMes)); +} + +const hideNavigationButtons = () => $('.immersive-navigation').remove(); + +function updateSwipesCounter($targetMes) { + if (!state.isActive) return; + + const $swipesCounter = $targetMes.find('.swipes-counter'); + if (!$swipesCounter.length) return; + + const mesId = $targetMes.attr('mesid'); + + if (mesId !== undefined) { + try { + const chat = getContext().chat; + const mesIndex = parseInt(mesId); + const message = chat?.[mesIndex]; + if (message?.swipes) { + const currentSwipeIndex = message.swipe_id || 0; + $swipesCounter.html(`${currentSwipeIndex + 1}​/​${message.swipes.length}`); + return; + } + } catch {} + } + $swipesCounter.html('1​/​1'); +} + +function toggleDisplayMode() { + if (!state.isActive) return; + + const settings = getSettings(); + settings.showAllMessages = !settings.showAllMessages; + applyModeClasses(); + updateMessageDisplay(); + saveSettingsDebounced(); +} + +function handleSwipe(swipeSelector, $targetMes) { + if (!state.isActive) return; + + const $btn = $targetMes.find(swipeSelector); + if ($btn.length) { + $btn.click(); + setTimeout(() => { + updateSwipesCounter($targetMes); + }, 100); + } +} + +function handleGlobalStateChange(event) { + const enabled = event.detail.enabled; + updateControlState(); + + if (enabled) { + const settings = getSettings(); + state.isActive = settings.enabled; + if (state.isActive) enableImmersiveMode(); + bindSettingsEvents(); + setTimeout(() => { + const checkbox = document.getElementById('xiaobaix_immersive_enabled'); + if (checkbox) checkbox.checked = settings.enabled; + }, 100); + } else { + if (state.isActive) disableImmersiveMode(); + state.isActive = false; + unbindSettingsEvents(); + } +} + +function onChatChanged() { + if (!isGlobalEnabled() || !state.isActive) return; + + setTimeout(() => { + moveAvatarWrappers(); + updateMessageDisplay(); + }, 100); +} + +function cleanup() { + if (state.isActive) disableImmersiveMode(); + destroyDOMObserver(); + + baseEvents.cleanup(); + + if (state.globalStateHandler) { + document.removeEventListener('xiaobaixEnabledChanged', state.globalStateHandler); + } + + unbindMessageEvents(); + detachResizeObserver(); + + state = { + isActive: false, + eventsBound: false, + messageEventsBound: false, + globalStateHandler: null + }; +} + +function attachResizeObserverTo(el) { + if (!el) return; + + if (!resizeObs) { + resizeObs = new ResizeObserver(() => {}); + } + + if (resizeObservedEl) detachResizeObserver(); + resizeObservedEl = el; + resizeObs.observe(el); +} + +function detachResizeObserver() { + if (resizeObs && resizeObservedEl) { + resizeObs.unobserve(resizeObservedEl); + } + resizeObservedEl = null; +} + +function destroyDOMObserver() { + if (observer) { + observer.disconnect(); + observer = null; + } +} + +export { initImmersiveMode, toggleImmersiveMode }; diff --git a/modules/message-preview.js b/modules/message-preview.js index c4686ec..128a394 100644 --- a/modules/message-preview.js +++ b/modules/message-preview.js @@ -1,650 +1,650 @@ -import { extension_settings, getContext } from "../../../../extensions.js"; -import { saveSettingsDebounced, eventSource, event_types } from "../../../../../script.js"; -import { EXT_ID } from "../core/constants.js"; - -const C = { MAX_HISTORY: 10, CHECK: 200, DEBOUNCE: 300, CLEAN: 300000, TARGET: "/api/backends/chat-completions/generate", TIMEOUT: 30, ASSOC_DELAY: 1000, REQ_WINDOW: 30000 }; -const S = { active: false, isPreview: false, isLong: false, isHistoryUiBound: false, previewData: null, previewIds: new Set(), interceptedIds: [], history: [], listeners: [], resolve: null, reject: null, sendBtnWasDisabled: false, longPressTimer: null, longPressDelay: 1000, chatLenBefore: 0, restoreLong: null, cleanTimer: null, previewAbort: null, tailAPI: null, genEndedOff: null, cleanupFallback: null, pendingPurge: false }; - -const $q = (sel) => $(sel); -const ON = (e, c) => eventSource.on(e, c); -const OFF = (e, c) => eventSource.removeListener(e, c); -const now = () => Date.now(); -const geEnabled = () => { try { return ("isXiaobaixEnabled" in window) ? !!window.isXiaobaixEnabled : true; } catch { return true; } }; -const debounce = (fn, w) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), w); }; }; -const safeJson = (t) => { try { return JSON.parse(t); } catch { return null; } }; - -const readText = async (b) => { try { if (!b) return ""; if (typeof b === "string") return b; if (b instanceof Blob) return await b.text(); if (b instanceof URLSearchParams) return b.toString(); if (typeof b === "object" && typeof b.text === "function") return await b.text(); } catch { } return ""; }; - -function isSafeBody(body) { if (!body) return true; return (typeof body === "string" || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof FormData !== "undefined" && body instanceof FormData)); } - -async function safeReadBodyFromInput(input, options) { try { if (input instanceof Request) return await readText(input.clone()); const body = options?.body; if (!isSafeBody(body)) return ""; return await readText(body); } catch { return ""; } } - -const isGen = (u) => String(u || "").includes(C.TARGET); -const isTarget = async (input, opt = {}) => { try { const url = input instanceof Request ? input.url : input; if (!isGen(url)) return false; const text = await safeReadBodyFromInput(input, opt); return text ? text.includes('"messages"') : true; } catch { return input instanceof Request ? isGen(input.url) : isGen(input); } }; -const getSettings = () => { const d = extension_settings[EXT_ID] || (extension_settings[EXT_ID] = {}); d.preview = d.preview || { enabled: false, timeoutSeconds: C.TIMEOUT }; d.recorded = d.recorded || { enabled: true }; d.preview.timeoutSeconds = C.TIMEOUT; return d; }; - -function injectPreviewModalStyles() { - if (document.getElementById('message-preview-modal-styles')) return; - const style = document.createElement('style'); - style.id = 'message-preview-modal-styles'; - style.textContent = ` - .mp-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none} - .mp-modal{ - width:clamp(360px,55vw,860px); - max-width:95vw; - background:var(--SmartThemeBlurTintColor); - border:2px solid var(--SmartThemeBorderColor); - border-radius:10px; - box-shadow:0 8px 16px var(--SmartThemeShadowColor); - pointer-events:auto; - display:flex; - flex-direction:column; - height:80vh; - max-height:calc(100vh - 60px); - resize:both; - overflow:hidden; - } - .mp-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move;flex-shrink:0} - .mp-body{height:60vh;overflow:auto;padding:10px;flex:1;min-height:160px} - .mp-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor);flex-shrink:0} - .mp-close{cursor:pointer} - .mp-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px} - .mp-search-input{padding:4px 8px;border:1px solid var(--SmartThemeBorderColor);border-radius:4px;background:var(--SmartThemeShadowColor);color:inherit;font-size:12px;width:120px} - .mp-search-btn{padding:4px 6px;font-size:12px;min-width:24px;text-align:center} - .mp-search-info{font-size:12px;opacity:.8;white-space:nowrap} - .message-preview-container{height:100%} - .message-preview-content-box{height:100%;overflow:auto} - .mp-highlight{background-color:yellow;color:black;padding:1px 2px;border-radius:2px} - .mp-highlight.current{background-color:orange;font-weight:bold} - @media (max-width:999px){ - .mp-overlay{position:absolute;inset:0;align-items:flex-start} - .mp-modal{width:100%;max-width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0;height:100vh;resize:none} - .mp-header{padding:8px 14px} - .mp-body{padding:8px} - .mp-footer{padding:8px 14px;flex-wrap:wrap;gap:6px} - .mp-search-input{width:150px} - } - `; - document.head.appendChild(style); -} - -function setupModalDrag(modal, overlay, header) { - modal.style.position = 'absolute'; - modal.style.left = '50%'; - modal.style.top = '50%'; - modal.style.transform = 'translate(-50%, -50%)'; - - let dragging = false, sx = 0, sy = 0, sl = 0, st = 0; - - function onDown(e) { - if (!(e instanceof PointerEvent) || e.button !== 0) return; - dragging = true; - const overlayRect = overlay.getBoundingClientRect(); - const rect = modal.getBoundingClientRect(); - modal.style.left = (rect.left - overlayRect.left) + 'px'; - modal.style.top = (rect.top - overlayRect.top) + 'px'; - modal.style.transform = ''; - sx = e.clientX; sy = e.clientY; - sl = parseFloat(modal.style.left) || 0; - st = parseFloat(modal.style.top) || 0; - window.addEventListener('pointermove', onMove, { passive: true }); - window.addEventListener('pointerup', onUp, { once: true }); - e.preventDefault(); - } - - function onMove(e) { - if (!dragging) return; - const dx = e.clientX - sx, dy = e.clientY - sy; - let nl = sl + dx, nt = st + dy; - const maxLeft = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth; - const maxTop = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight; - nl = Math.max(0, Math.min(maxLeft, nl)); - nt = Math.max(0, Math.min(maxTop, nt)); - modal.style.left = nl + 'px'; - modal.style.top = nt + 'px'; - } - - function onUp() { - dragging = false; - window.removeEventListener('pointermove', onMove); - } - - header.addEventListener('pointerdown', onDown); -} - -function createMovableModal(title, content) { - injectPreviewModalStyles(); - const overlay = document.createElement('div'); - overlay.className = 'mp-overlay'; - const modal = document.createElement('div'); - modal.className = 'mp-modal'; - const header = document.createElement('div'); - header.className = 'mp-header'; - header.innerHTML = `${title}`; - const body = document.createElement('div'); - body.className = 'mp-body'; - body.innerHTML = content; - const footer = document.createElement('div'); - footer.className = 'mp-footer'; - footer.innerHTML = ` - - - - - - - - `; - modal.appendChild(header); - modal.appendChild(body); - modal.appendChild(footer); - overlay.appendChild(modal); - setupModalDrag(modal, overlay, header); - - let searchResults = []; - let currentIndex = -1; - const searchInput = footer.querySelector('.mp-search-input'); - const searchInfo = footer.querySelector('#mp-search-info'); - const prevBtn = footer.querySelector('#mp-search-prev'); - const nextBtn = footer.querySelector('#mp-search-next'); - - function clearHighlights() { body.querySelectorAll('.mp-highlight').forEach(el => { el.outerHTML = el.innerHTML; }); } - function performSearch(query) { - clearHighlights(); - searchResults = []; - currentIndex = -1; - if (!query.trim()) { searchInfo.textContent = ''; return; } - const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false); - const nodes = []; - let node; - while (node = walker.nextNode()) { nodes.push(node); } - const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); - nodes.forEach(textNode => { - const text = textNode.textContent; - if (!text || !regex.test(text)) return; - let html = text; - let offset = 0; - regex.lastIndex = 0; - const matches = [...text.matchAll(regex)]; - matches.forEach((m) => { - const start = m.index + offset; - const end = start + m[0].length; - const before = html.slice(0, start); - const mid = html.slice(start, end); - const after = html.slice(end); - const span = `${mid}`; - html = before + span + after; - offset += span.length - m[0].length; - searchResults.push({}); - }); - const parent = textNode.parentElement; - parent.innerHTML = parent.innerHTML.replace(text, html); - }); - updateSearchInfo(); - if (searchResults.length > 0) { currentIndex = 0; highlightCurrent(); } - } - function updateSearchInfo() { if (!searchResults.length) searchInfo.textContent = searchInput.value.trim() ? '无结果' : ''; else searchInfo.textContent = `${currentIndex + 1}/${searchResults.length}`; } - function highlightCurrent() { - body.querySelectorAll('.mp-highlight.current').forEach(el => el.classList.remove('current')); - if (currentIndex >= 0 && currentIndex < searchResults.length) { - const el = body.querySelector(`.mp-highlight[data-search-index="${currentIndex}"]`); - if (el) { el.classList.add('current'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } - } - } - function navigateSearch(direction) { - if (!searchResults.length) return; - if (direction === 'next') currentIndex = (currentIndex + 1) % searchResults.length; - else currentIndex = currentIndex <= 0 ? searchResults.length - 1 : currentIndex - 1; - updateSearchInfo(); - highlightCurrent(); - } - let searchTimeout; - searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => performSearch(e.target.value), 250); }); - searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) navigateSearch('prev'); else navigateSearch('next'); } else if (e.key === 'Escape') { searchInput.value = ''; performSearch(''); } }); - prevBtn.addEventListener('click', () => navigateSearch('prev')); - nextBtn.addEventListener('click', () => navigateSearch('next')); - footer.querySelector('#mp-focus-search')?.addEventListener('click', () => { searchInput.focus(); if (searchInput.value) navigateSearch('next'); }); - - const close = () => overlay.remove(); - header.querySelector('.mp-close').addEventListener('click', close); - footer.querySelector('#mp-close').addEventListener('click', close); - footer.querySelector('#mp-toggle-format').addEventListener('click', (e) => { - const box = body.querySelector(".message-preview-content-box"); - const f = box?.querySelector(".mp-state-formatted"); - const r = box?.querySelector(".mp-state-raw"); - if (!(f && r)) return; - const showRaw = r.style.display === "none"; - r.style.display = showRaw ? "block" : "none"; - f.style.display = showRaw ? "none" : "block"; - e.currentTarget.textContent = showRaw ? "切换整理格式" : "切换原始格式"; - searchInput.value = ""; - clearHighlights(); - searchInfo.textContent = ""; - searchResults = []; - currentIndex = -1; - }); - - document.body.appendChild(overlay); - return { overlay, modal, body, close }; -} - -const MIRROR = { MERGE: "merge", MERGE_TOOLS: "merge_tools", SEMI: "semi", SEMI_TOOLS: "semi_tools", STRICT: "strict", STRICT_TOOLS: "strict_tools", SINGLE: "single" }; -const roleMap = { system: { label: "SYSTEM:", color: "#F7E3DA" }, user: { label: "USER:", color: "#F0ADA7" }, assistant: { label: "ASSISTANT:", color: "#6BB2CC" } }; -const colorXml = (t) => (typeof t === "string" ? t.replace(/<([^>]+)>/g, '<$1>') : t); -const getNames = (req) => { const n = { charName: String(req?.char_name || ""), userName: String(req?.user_name || ""), groupNames: Array.isArray(req?.group_names) ? req.group_names.map(String) : [] }; n.startsWithGroupName = (m) => n.groupNames.some((g) => String(m || "").startsWith(`${g}: `)); return n; }; -const toText = (m) => { const c = m?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c.map((p) => p?.type === "text" ? String(p.text || "") : p?.type === "image_url" ? "[image]" : p?.type === "video_url" ? "[video]" : typeof p === "string" ? p : (typeof p?.content === "string" ? p.content : "")).filter(Boolean).join("\n\n"); return String(c || ""); }; -const applyName = (m, n) => { const { role, name } = m; let t = toText(m); if (role === "system" && name === "example_assistant") { if (n.charName && !t.startsWith(`${n.charName}: `) && !n.startsWithGroupName(t)) t = `${n.charName}: ${t}`; } else if (role === "system" && name === "example_user") { if (n.userName && !t.startsWith(`${n.userName}: `)) t = `${n.userName}: ${t}`; } else if (name && role !== "system" && !t.startsWith(`${name}: `)) t = `${name}: ${t}`; return { ...m, content: t, name: undefined }; }; -function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) { - if (!Array.isArray(messages)) return []; - let mapped = messages.map((m) => applyName({ ...m }, names)).map((x) => { const m = { ...x }; if (!tools) { if (m.role === "tool") m.role = "user"; delete m.tool_calls; delete m.tool_call_id; } if (single) { if (m.role === "assistant") { const t = String(m.content || ""); if (names.charName && !t.startsWith(`${names.charName}: `) && !names.startsWithGroupName(t)) m.content = `${names.charName}: ${t}`; } if (m.role === "user") { const t = String(m.content || ""); if (names.userName && !t.startsWith(`${names.userName}: `)) m.content = `${names.userName}: ${t}`; } m.role = "user"; } return m; }); - const squash = (arr) => { const out = []; for (const m of arr) { if (out.length && out[out.length - 1].role === m.role && String(m.content || "").length && m.role !== "tool") out[out.length - 1].content += `\n\n${m.content}`; else out.push(m); } return out; }; - let sq = squash(mapped); - if (strict) { for (let i = 0; i < sq.length; i++) if (i > 0 && sq[i].role === "system") sq[i].role = "user"; if (placeholders) { if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); else if (sq[0].role === "system" && (sq.length === 1 || sq[1].role !== "user")) sq.splice(1, 0, { role: "user", content: "[Start a new chat]" }); else if (sq[0].role !== "system" && sq[0].role !== "user") sq.unshift({ role: "user", content: "[Start a new chat]" }); } return squash(sq); } - if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); - return sq; -} -function mirror(requestData) { - try { - let type = String(requestData?.custom_prompt_post_processing || "").toLowerCase(); - const source = String(requestData?.chat_completion_source || "").toLowerCase(); - if (source === "perplexity") type = MIRROR.STRICT; - const names = getNames(requestData || {}), src = Array.isArray(requestData?.messages) ? JSON.parse(JSON.stringify(requestData.messages)) : []; - const mk = (o) => mergeMessages(src, names, o); - switch (type) { - case MIRROR.MERGE: return mk({ strict: false }); - case MIRROR.MERGE_TOOLS: return mk({ strict: false, tools: true }); - case MIRROR.SEMI: return mk({ strict: true }); - case MIRROR.SEMI_TOOLS: return mk({ strict: true, tools: true }); - case MIRROR.STRICT: return mk({ strict: true, placeholders: true }); - case MIRROR.STRICT_TOOLS: return mk({ strict: true, placeholders: true, tools: true }); - case MIRROR.SINGLE: return mk({ strict: true, single: true }); - default: return src; - } - } catch { return Array.isArray(requestData?.messages) ? requestData.messages : []; } -} -const finalMsgs = (d) => { try { if (d?.requestData?.messages) return mirror(d.requestData); if (Array.isArray(d?.messages)) return d.messages; return []; } catch { return Array.isArray(d?.messages) ? d.messages : []; } }; -const formatPreview = (d) => { - const msgs = finalMsgs(d); - let out = `↓酒馆日志↓(${msgs.length})\n${"-".repeat(30)}\n`; - msgs.forEach((m, i) => { - const txt = m.content || ""; - const rm = roleMap[m.role] || { label: `${String(m.role || "").toUpperCase()}:`, color: "#FFF" }; - out += `
${rm.label}
`; - out += /<[^>]+>/g.test(txt) ? `
${colorXml(txt)}
` : `
${txt}
`; - }); - return out; -}; -const stripTop = (o) => { try { if (!o || typeof o !== "object") return o; if (Array.isArray(o)) return o; const messages = Array.isArray(o.messages) ? JSON.parse(JSON.stringify(o.messages)) : undefined; return typeof messages !== "undefined" ? { messages } : {}; } catch { return {}; } }; -const formatRaw = (d) => { try { const hasReq = Array.isArray(d?.requestData?.messages), hasMsgs = !hasReq && Array.isArray(d?.messages); let obj; if (hasReq) { const req = JSON.parse(JSON.stringify(d.requestData)); try { req.messages = mirror(req); } catch { } obj = req; } else if (hasMsgs) { const fake = { ...(d || {}), messages: d.messages }; let mm = null; try { mm = mirror(fake); } catch { } obj = { ...(d || {}), messages: mm || d.messages }; } else obj = d?.requestData ?? d; obj = stripTop(obj); return colorXml(JSON.stringify(obj, null, 2)); } catch { try { return colorXml(String(d)); } catch { return ""; } } }; -const buildPreviewHtml = (d) => { const formatted = formatPreview(d), raw = formatRaw(d); return `
${formatted}
`; }; -const openPopup = async (html, title) => { createMovableModal(title, html); }; -const displayPreview = async (d) => { try { await openPopup(buildPreviewHtml(d), "消息拦截"); } catch { toastr.error("显示拦截失败"); } }; - -const pushHistory = (r) => { S.history.unshift(r); if (S.history.length > C.MAX_HISTORY) S.history.length = C.MAX_HISTORY; }; -const extractUser = (ms) => { if (!Array.isArray(ms)) return ""; for (let i = ms.length - 1; i >= 0; i--) if (ms[i]?.role === "user") return ms[i].content || ""; return ""; }; - -async function recordReal(input, options) { - try { - const url = input instanceof Request ? input.url : input; - const body = await safeReadBodyFromInput(input, options); - if (!body) return; - const data = safeJson(body) || {}, ctx = getContext(); - pushHistory({ url, method: options?.method || (input instanceof Request ? input.method : "POST"), requestData: data, messages: data.messages || [], model: data.model || "Unknown", timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", userInput: extractUser(data.messages || []), isRealRequest: true }); - setTimeout(() => { if (S.history[0] && !S.history[0].associatedMessageId) S.history[0].associatedMessageId = ctx.chat?.length || 0; }, C.ASSOC_DELAY); - } catch { } -} - -const findRec = (id) => { - if (!S.history.length) return null; - const preds = [(r) => r.associatedMessageId === id, (r) => r.messageId === id, (r) => r.messageId === id - 1, (r) => Math.abs(r.messageId - id) <= 1]; - for (const p of preds) { const m = S.history.find(p); if (m) return m; } - const cs = S.history.filter((r) => r.messageId <= id + 2); - return cs.length ? cs.sort((a, b) => b.messageId - a.messageId)[0] : S.history[0]; -}; - -// Improved purgePreviewArtifacts - follows SillyTavern's batch delete pattern -async function purgePreviewArtifacts() { - try { - if (!S.pendingPurge) return; - S.pendingPurge = false; - const ctx = getContext(); - const chat = Array.isArray(ctx.chat) ? ctx.chat : []; - const start = Math.max(0, Number(S.chatLenBefore) || 0); - if (start >= chat.length) return; - - // 1. Remove DOM elements (following SillyTavern's pattern from #dialogue_del_mes_ok) - const $chat = $('#chat'); - $chat.find(`.mes[mesid="${start}"]`).nextAll('.mes').addBack().remove(); - - // 2. Truncate chat array - chat.length = start; - - // 3. Update last_mes class - $('#chat .mes').removeClass('last_mes'); - $('#chat .mes').last().addClass('last_mes'); - - // 4. Save chat and emit MESSAGE_DELETED event (critical for other plugins) - ctx.saveChat?.(); - await eventSource.emit(event_types.MESSAGE_DELETED, start); - } catch (e) { - console.error('[message-preview] purgePreviewArtifacts error', e); - } -} - - - -function oneShotOnLast(ev, handler) { - const wrapped = (...args) => { - try { handler(...args); } finally { off(); } - }; - let off = () => { }; - if (typeof eventSource.makeLast === "function") { - eventSource.makeLast(ev, wrapped); - off = () => { - try { eventSource.removeListener?.(ev, wrapped); } catch { } - try { eventSource.off?.(ev, wrapped); } catch { } - }; - } else if (S.tailAPI?.onLast) { - const disposer = S.tailAPI.onLast(ev, wrapped); - off = () => { try { disposer?.(); } catch { } }; - } else { - eventSource.on(ev, wrapped); - off = () => { try { eventSource.removeListener?.(ev, wrapped); } catch { } }; - } - return off; -} - -function installEventSourceTail(es) { - if (!es || es.__lw_tailInstalled) return es?.__lw_tailAPI || null; - const SYM = { MW_STACK: Symbol.for("lwbox.es.emitMiddlewareStack"), BASE: Symbol.for("lwbox.es.emitBase"), ORIG_DESC: Symbol.for("lwbox.es.emit.origDesc"), COMPOSED: Symbol.for("lwbox.es.emit.composed"), ID: Symbol.for("lwbox.middleware.identity") }; - const getFnFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(es); if (typeof v === "function") return v; } } catch { } return es.emit?.bind?.(es) || es.emit; }; - const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base); - const tails = new Map(); - const addTail = (ev, fn) => { if (typeof fn !== "function") return () => { }; const arr = tails.get(ev) || []; arr.push(fn); tails.set(ev, arr); return () => { const a = tails.get(ev); if (!a) return; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }; }; - const runTails = (ev, args) => { const arr = tails.get(ev); if (!arr?.length) return; for (const h of arr.slice()) { try { h(...args); } catch (e) { } } }; - const makeTailMw = () => { const mw = (next) => function patchedEmit(ev, ...args) { let r; try { r = next.call(this, ev, ...args); } catch (e) { queueMicrotask(() => runTails(ev, args)); throw e; } if (r && typeof r.then === "function") r.finally(() => runTails(ev, args)); else queueMicrotask(() => runTails(ev, args)); return r; }; Object.defineProperty(mw, SYM.ID, { value: true }); return Object.freeze(mw); }; - const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(es, "emit"); if (!es[SYM.ORIG_DESC]) es[SYM.ORIG_DESC] = d || null; es[SYM.BASE] ||= getFnFromDesc(d); Object.defineProperty(es, "emit", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { es[SYM.BASE] = v; queueMicrotask(reapply); } } }); } catch { } }; - const reapply = () => { try { const base = es[SYM.BASE] || getFnFromDesc(Object.getOwnPropertyDescriptor(es, "emit")) || es.emit.bind(es); const stack = es[SYM.MW_STACK] || (es[SYM.MW_STACK] = []); let idx = stack.findIndex((m) => m && m[SYM.ID]); if (idx === -1) { stack.push(makeTailMw()); idx = stack.length - 1; } if (idx !== stack.length - 1) { const mw = stack[idx]; stack.splice(idx, 1); stack.push(mw); } const composed = compose(base, stack) || base; if (!es[SYM.COMPOSED] || es[SYM.COMPOSED]._base !== base || es[SYM.COMPOSED]._stack !== stack) { composed._base = base; composed._stack = stack; es[SYM.COMPOSED] = composed; } return es[SYM.COMPOSED]; } catch { return es.emit; } }; - ensureAccessor(); - queueMicrotask(reapply); - const api = { onLast: (e, h) => addTail(e, h), removeLast: (e, h) => { const a = tails.get(e); if (!a) return; const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); }, uninstall() { try { const s = es[SYM.MW_STACK]; const i = Array.isArray(s) ? s.findIndex((m) => m && m[SYM.ID]) : -1; if (i >= 0) s.splice(i, 1); const orig = es[SYM.ORIG_DESC]; if (orig) { try { Object.defineProperty(es, "emit", orig); } catch { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } else { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } catch { } delete es.__lw_tailInstalled; delete es.__lw_tailAPI; tails.clear(); } }; - Object.defineProperty(es, "__lw_tailInstalled", { value: true }); - Object.defineProperty(es, "__lw_tailAPI", { value: api }); - return api; -} - -let __installed = false; -const MW_KEY = Symbol.for("lwbox.fetchMiddlewareStack"); -const BASE_KEY = Symbol.for("lwbox.fetchBase"); -const ORIG_KEY = Symbol.for("lwbox.fetch.origDesc"); -const CMP_KEY = Symbol.for("lwbox.fetch.composed"); -const ID = Symbol.for("lwbox.middleware.identity"); -const getFetchFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(window); if (typeof v === "function") return v; } } catch { } return globalThis.fetch; }; -const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base); -const withTimeout = (p, ms = 200) => { try { return Promise.race([p, new Promise((r) => setTimeout(r, ms))]); } catch { return p; } }; -const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(window, "fetch"); if (!window[ORIG_KEY]) window[ORIG_KEY] = d || null; window[BASE_KEY] ||= getFetchFromDesc(d); Object.defineProperty(window, "fetch", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { window[BASE_KEY] = v; queueMicrotask(reapply); } } }); } catch { } }; -const reapply = () => { try { const base = window[BASE_KEY] || getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); const stack = window[MW_KEY] || (window[MW_KEY] = []); let idx = stack.findIndex((m) => m && m[ID]); if (idx === -1) { stack.push(makeMw()); idx = stack.length - 1; } if (idx !== window[MW_KEY].length - 1) { const mw = window[MW_KEY][idx]; window[MW_KEY].splice(idx, 1); window[MW_KEY].push(mw); } const composed = compose(base, stack) || base; if (!window[CMP_KEY] || window[CMP_KEY]._base !== base || window[CMP_KEY]._stack !== stack) { composed._base = base; composed._stack = stack; window[CMP_KEY] = composed; } return window[CMP_KEY]; } catch { return globalThis.fetch; } }; -function makeMw() { - const mw = (next) => async function f(input, options = {}) { - try { - if (await isTarget(input, options)) { - if (S.isPreview || S.isLong) { - const url = input instanceof Request ? input.url : input; - return interceptPreview(url, options).catch(() => new Response(JSON.stringify({ error: { message: "拦截失败,请手动中止消息生成。" } }), { status: 200, headers: { "Content-Type": "application/json" } })); - } else { try { await withTimeout(recordReal(input, options)); } catch { } } - } - } catch { } - return Reflect.apply(next, this, arguments); - }; - Object.defineProperty(mw, ID, { value: true, enumerable: false }); - return Object.freeze(mw); -} -function installFetch() { - if (__installed) return; __installed = true; - try { - window[MW_KEY] ||= []; - window[BASE_KEY] ||= getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); - ensureAccessor(); - if (!window[MW_KEY].some((m) => m && m[ID])) window[MW_KEY].push(makeMw()); - else { - const i = window[MW_KEY].findIndex((m) => m && m[ID]); - if (i !== window[MW_KEY].length - 1) { - const mw = window[MW_KEY][i]; - window[MW_KEY].splice(i, 1); - window[MW_KEY].push(mw); - } - } - queueMicrotask(reapply); - window.addEventListener("pageshow", reapply, { passive: true }); - document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reapply(); }, { passive: true }); - window.addEventListener("focus", reapply, { passive: true }); - } catch { } -} -function uninstallFetch() { - if (!__installed) return; - try { - const s = window[MW_KEY]; - const i = Array.isArray(s) ? s.findIndex((m) => m && m[ID]) : -1; - if (i >= 0) s.splice(i, 1); - const others = Array.isArray(window[MW_KEY]) && window[MW_KEY].length; - const orig = window[ORIG_KEY]; - if (!others) { - if (orig) { - try { Object.defineProperty(window, "fetch", orig); } - catch { Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); } - } else { - Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); - } - } else { - reapply(); - } - } catch { } - __installed = false; -} -const setupFetch = () => { if (!S.active) { installFetch(); S.active = true; } }; -const restoreFetch = () => { if (S.active) { uninstallFetch(); S.active = false; } }; -const updateFetchState = () => { const st = getSettings(), need = (st.preview.enabled || st.recorded.enabled); if (need && !S.active) setupFetch(); if (!need && S.active) restoreFetch(); }; - -async function interceptPreview(url, options) { - const body = await safeReadBodyFromInput(url, options); - const data = safeJson(body) || {}; - const userInput = extractUser(data?.messages || []); - const ctx = getContext(); - - if (S.isLong) { - const chat = Array.isArray(ctx.chat) ? ctx.chat : []; - let start = chat.length; - if (chat.length > 0 && chat[chat.length - 1]?.is_user === true) start = chat.length - 1; - S.chatLenBefore = start; - S.pendingPurge = true; - oneShotOnLast(event_types.GENERATION_ENDED, () => setTimeout(() => purgePreviewArtifacts(), 0)); - } - - S.previewData = { url, method: options?.method || "POST", requestData: data, messages: data?.messages || [], model: data?.model || "Unknown", timestamp: now(), userInput, isPreview: true }; - if (S.isLong) { setTimeout(() => { displayPreview(S.previewData); }, 100); } else if (S.resolve) { S.resolve({ success: true, data: S.previewData }); S.resolve = S.reject = null; } - const payload = S.isLong ? { choices: [{ message: { content: "【小白X】已拦截消息" }, finish_reason: "stop" }], intercepted: true } : { choices: [{ message: { content: "" }, finish_reason: "stop" }] }; - return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } }); -} - -const addHistoryButtonsDebounced = debounce(() => { - const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return; - $(".mes_history_preview").remove(); - $("#chat .mes").each(function () { - const id = parseInt($(this).attr("mesid")), isUser = $(this).attr("is_user") === "true"; - if (id <= 0 || isUser) return; - const btn = $(`
`).on("click", (e) => { e.preventDefault(); e.stopPropagation(); showHistoryPreview(id); }); - if (window.registerButtonToSubContainer && window.registerButtonToSubContainer(id, btn[0])) return; - $(this).find(".flex-container.flex1.alignitemscenter").append(btn); - }); -}, C.DEBOUNCE); - -const disableSend = (dis = true) => { - const $b = $q("#send_but"); - if (dis) { S.sendBtnWasDisabled = $b.prop("disabled"); $b.prop("disabled", true).off("click.preview-block").on("click.preview-block", (e) => { e.preventDefault(); e.stopImmediatePropagation(); return false; }); } - else { $b.prop("disabled", S.sendBtnWasDisabled).off("click.preview-block"); S.sendBtnWasDisabled = false; } -}; -const triggerSend = () => { - const $b = $q("#send_but"), $t = $q("#send_textarea"), txt = String($t.val() || ""); if (!txt.trim()) return false; - const was = $b.prop("disabled"); $b.prop("disabled", false); $b[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); if (was) $b.prop("disabled", true); return true; -}; - -async function showPreview() { - let toast = null, backup = null; - try { - const set = getSettings(); if (!set.preview.enabled || !geEnabled()) return toastr.warning("消息拦截功能未启用"); - const text = String($q("#send_textarea").val() || "").trim(); if (!text) return toastr.error("请先输入消息内容"); - - backup = text; disableSend(true); - const ctx = getContext(); - S.chatLenBefore = Array.isArray(ctx.chat) ? ctx.chat.length : 0; - S.isPreview = true; S.previewData = null; S.previewIds.clear(); S.previewAbort = new AbortController(); - S.pendingPurge = true; - - const endHandler = () => { - try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { } - if (S.pendingPurge) { - setTimeout(() => purgePreviewArtifacts(), 0); - } - }; - - S.genEndedOff = oneShotOnLast(event_types.GENERATION_ENDED, endHandler); - clearTimeout(S.cleanupFallback); - S.cleanupFallback = setTimeout(() => { - try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { } - purgePreviewArtifacts(); - }, 1500); - - toast = toastr.info(`正在拦截请求...(${set.preview.timeoutSeconds}秒超时)`, "消息拦截", { timeOut: 0, tapToDismiss: false }); - - if (!triggerSend()) throw new Error("无法触发发送事件"); - - const res = await waitIntercept().catch((e) => ({ success: false, error: e?.message || e })); - if (toast) { toastr.clear(toast); toast = null; } - if (res.success) { await displayPreview(res.data); toastr.success("拦截成功!", "", { timeOut: 3000 }); } - else toastr.error(`拦截失败: ${res.error}`, "", { timeOut: 5000 }); - } catch (e) { - if (toast) toastr.clear(toast); toastr.error(`拦截异常: ${e.message}`, "", { timeOut: 5000 }); - } finally { - try { S.previewAbort?.abort("拦截结束"); } catch { } S.previewAbort = null; - if (S.resolve) S.resolve({ success: false, error: "拦截已取消" }); S.resolve = S.reject = null; - clearTimeout(S.cleanupFallback); S.cleanupFallback = null; - S.isPreview = false; S.previewData = null; - disableSend(false); if (backup) $q("#send_textarea").val(backup); - } -} - -async function showHistoryPreview(messageId) { - try { - const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return; - const rec = findRec(messageId); - if (rec?.messages?.length || rec?.requestData?.messages?.length) await openPopup(buildPreviewHtml({ ...rec, isHistoryPreview: true, targetMessageId: messageId }), `消息历史查看 - 第 ${messageId + 1} 条消息`); - else toastr.warning(`未找到第 ${messageId + 1} 条消息的API请求记录`); - } catch { toastr.error("查看历史消息失败"); } -} - -const cleanupMemory = () => { - if (S.history.length > C.MAX_HISTORY) S.history = S.history.slice(0, C.MAX_HISTORY); - S.previewIds.clear(); S.previewData = null; $(".mes_history_preview").each(function () { if (!$(this).closest(".mes").length) $(this).remove(); }); - if (!S.isLong) S.interceptedIds = []; -}; - -function onLast(ev, handler) { - if (typeof eventSource.makeLast === "function") { eventSource.makeLast(ev, handler); S.listeners.push({ e: ev, h: handler, off: () => { } }); return; } - if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(ev, handler); S.listeners.push({ e: ev, h: handler, off }); return; } - const tail = (...args) => queueMicrotask(() => { try { handler(...args); } catch { } }); - eventSource.on(ev, tail); - S.listeners.push({ e: ev, h: tail, off: () => eventSource.removeListener?.(ev, tail) }); -} - -const addEvents = () => { - removeEvents(); - [ - { e: event_types.MESSAGE_RECEIVED, h: addHistoryButtonsDebounced }, - { e: event_types.CHARACTER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced }, - { e: event_types.USER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced }, - { e: event_types.CHAT_CHANGED, h: () => { S.history = []; setTimeout(addHistoryButtonsDebounced, C.CHECK); } }, - { e: event_types.MESSAGE_RECEIVED, h: (messageId) => setTimeout(() => { const r = S.history.find((x) => !x.associatedMessageId && now() - x.timestamp < C.REQ_WINDOW); if (r) r.associatedMessageId = messageId; }, 100) }, - ].forEach(({ e, h }) => onLast(e, h)); - const late = (payload) => { - try { - const ctx = getContext(); - pushHistory({ - url: C.TARGET, method: "POST", requestData: payload, messages: payload?.messages || [], model: payload?.model || "Unknown", - timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", - userInput: extractUser(payload?.messages || []), isRealRequest: true, source: "settings_ready", - }); - } catch { } - queueMicrotask(() => updateFetchState()); - }; - if (typeof eventSource.makeLast === "function") { eventSource.makeLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => { } }); } - else if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off }); } - else { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late) }); queueMicrotask(() => { try { OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } try { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } }); } -}; -const removeEvents = () => { S.listeners.forEach(({ e, h, off }) => { if (typeof off === "function") { try { off(); } catch { } } else { try { OFF(e, h); } catch { } } }); S.listeners = []; }; - -const toggleLong = () => { - S.isLong = !S.isLong; - const $b = $q("#message_preview_btn"); - if (S.isLong) { - $b.css("color", "red"); - toastr.info("持续拦截已开启", "", { timeOut: 2000 }); - } else { - $b.css("color", ""); - S.pendingPurge = false; - toastr.info("持续拦截已关闭", "", { timeOut: 2000 }); - } -}; -const bindBtn = () => { - const $b = $q("#message_preview_btn"); - $b.on("mousedown touchstart", () => { S.longPressTimer = setTimeout(() => toggleLong(), S.longPressDelay); }); - $b.on("mouseup touchend mouseleave", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } }); - $b.on("click", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; return; } if (!S.isLong) showPreview(); }); -}; - -const waitIntercept = () => new Promise((resolve, reject) => { - const t = setTimeout(() => { if (S.resolve) { S.resolve({ success: false, error: `等待超时 (${getSettings().preview.timeoutSeconds}秒)` }); S.resolve = S.reject = null; } }, getSettings().preview.timeoutSeconds * 1000); - S.resolve = (v) => { clearTimeout(t); resolve(v); }; S.reject = (e) => { clearTimeout(t); reject(e); }; -}); - -function cleanup() { - removeEvents(); restoreFetch(); disableSend(false); - $(".mes_history_preview").remove(); $("#message_preview_btn").remove(); cleanupMemory(); - Object.assign(S, { resolve: null, reject: null, isPreview: false, isLong: false, interceptedIds: [], chatLenBefore: 0, sendBtnWasDisabled: false, pendingPurge: false }); - if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } - if (S.restoreLong) { try { S.restoreLong(); } catch { } S.restoreLong = null; } - if (S.genEndedOff) { try { S.genEndedOff(); } catch { } S.genEndedOff = null; } - if (S.cleanupFallback) { clearTimeout(S.cleanupFallback); S.cleanupFallback = null; } -} - -function initMessagePreview() { - try { - cleanup(); S.tailAPI = installEventSourceTail(eventSource); - const set = getSettings(); - const btn = $(`
`); - $("#send_but").before(btn); bindBtn(); - $("#xiaobaix_preview_enabled").prop("checked", set.preview.enabled).on("change", function () { - if (!geEnabled()) return; set.preview.enabled = $(this).prop("checked"); saveSettingsDebounced(); - $("#message_preview_btn").toggle(set.preview.enabled); - if (set.preview.enabled) { if (!S.cleanTimer) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); } - else { if (S.cleanTimer) { clearInterval(S.cleanTimer); S.cleanTimer = null; } } - updateFetchState(); - if (!set.preview.enabled && set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); } - }); - $("#xiaobaix_recorded_enabled").prop("checked", set.recorded.enabled).on("change", function () { - if (!geEnabled()) return; set.recorded.enabled = $(this).prop("checked"); saveSettingsDebounced(); - if (set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); } - else { $(".mes_history_preview").remove(); S.history.length = 0; if (!set.preview.enabled) removeEvents(); } - updateFetchState(); - }); - if (!set.preview.enabled) $("#message_preview_btn").hide(); - updateFetchState(); if (set.recorded.enabled) addHistoryButtonsDebounced(); - if (set.preview.enabled || set.recorded.enabled) addEvents(); - if (window.registerModuleCleanup) window.registerModuleCleanup("messagePreview", cleanup); - if (set.preview.enabled) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); - } catch { toastr.error("模块初始化失败"); } -} - -window.addEventListener("beforeunload", cleanup); -window.messagePreviewCleanup = cleanup; - +import { extension_settings, getContext } from "../../../../extensions.js"; +import { saveSettingsDebounced, eventSource, event_types } from "../../../../../script.js"; +import { EXT_ID } from "../core/constants.js"; + +const C = { MAX_HISTORY: 10, CHECK: 200, DEBOUNCE: 300, CLEAN: 300000, TARGET: "/api/backends/chat-completions/generate", TIMEOUT: 30, ASSOC_DELAY: 1000, REQ_WINDOW: 30000 }; +const S = { active: false, isPreview: false, isLong: false, isHistoryUiBound: false, previewData: null, previewIds: new Set(), interceptedIds: [], history: [], listeners: [], resolve: null, reject: null, sendBtnWasDisabled: false, longPressTimer: null, longPressDelay: 1000, chatLenBefore: 0, restoreLong: null, cleanTimer: null, previewAbort: null, tailAPI: null, genEndedOff: null, cleanupFallback: null, pendingPurge: false }; + +const $q = (sel) => $(sel); +const ON = (e, c) => eventSource.on(e, c); +const OFF = (e, c) => eventSource.removeListener(e, c); +const now = () => Date.now(); +const geEnabled = () => { try { return ("isXiaobaixEnabled" in window) ? !!window.isXiaobaixEnabled : true; } catch { return true; } }; +const debounce = (fn, w) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), w); }; }; +const safeJson = (t) => { try { return JSON.parse(t); } catch { return null; } }; + +const readText = async (b) => { try { if (!b) return ""; if (typeof b === "string") return b; if (b instanceof Blob) return await b.text(); if (b instanceof URLSearchParams) return b.toString(); if (typeof b === "object" && typeof b.text === "function") return await b.text(); } catch { } return ""; }; + +function isSafeBody(body) { if (!body) return true; return (typeof body === "string" || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || (typeof FormData !== "undefined" && body instanceof FormData)); } + +async function safeReadBodyFromInput(input, options) { try { if (input instanceof Request) return await readText(input.clone()); const body = options?.body; if (!isSafeBody(body)) return ""; return await readText(body); } catch { return ""; } } + +const isGen = (u) => String(u || "").includes(C.TARGET); +const isTarget = async (input, opt = {}) => { try { const url = input instanceof Request ? input.url : input; if (!isGen(url)) return false; const text = await safeReadBodyFromInput(input, opt); return text ? text.includes('"messages"') : true; } catch { return input instanceof Request ? isGen(input.url) : isGen(input); } }; +const getSettings = () => { const d = extension_settings[EXT_ID] || (extension_settings[EXT_ID] = {}); d.preview = d.preview || { enabled: false, timeoutSeconds: C.TIMEOUT }; d.recorded = d.recorded || { enabled: true }; d.preview.timeoutSeconds = C.TIMEOUT; return d; }; + +function injectPreviewModalStyles() { + if (document.getElementById('message-preview-modal-styles')) return; + const style = document.createElement('style'); + style.id = 'message-preview-modal-styles'; + style.textContent = ` + .mp-overlay{position:fixed;inset:0;background:none;z-index:9999;display:flex;align-items:center;justify-content:center;pointer-events:none} + .mp-modal{ + width:clamp(360px,55vw,860px); + max-width:95vw; + background:var(--SmartThemeBlurTintColor); + border:2px solid var(--SmartThemeBorderColor); + border-radius:10px; + box-shadow:0 8px 16px var(--SmartThemeShadowColor); + pointer-events:auto; + display:flex; + flex-direction:column; + height:80vh; + max-height:calc(100vh - 60px); + resize:both; + overflow:hidden; + } + .mp-header{display:flex;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--SmartThemeBorderColor);font-weight:600;cursor:move;flex-shrink:0} + .mp-body{height:60vh;overflow:auto;padding:10px;flex:1;min-height:160px} + .mp-footer{display:flex;gap:8px;justify-content:flex-end;padding:12px 14px;border-top:1px solid var(--SmartThemeBorderColor);flex-shrink:0} + .mp-close{cursor:pointer} + .mp-btn{cursor:pointer;border:1px solid var(--SmartThemeBorderColor);background:var(--SmartThemeBlurTintColor);padding:6px 10px;border-radius:6px} + .mp-search-input{padding:4px 8px;border:1px solid var(--SmartThemeBorderColor);border-radius:4px;background:var(--SmartThemeShadowColor);color:inherit;font-size:12px;width:120px} + .mp-search-btn{padding:4px 6px;font-size:12px;min-width:24px;text-align:center} + .mp-search-info{font-size:12px;opacity:.8;white-space:nowrap} + .message-preview-container{height:100%} + .message-preview-content-box{height:100%;overflow:auto} + .mp-highlight{background-color:yellow;color:black;padding:1px 2px;border-radius:2px} + .mp-highlight.current{background-color:orange;font-weight:bold} + @media (max-width:999px){ + .mp-overlay{position:absolute;inset:0;align-items:flex-start} + .mp-modal{width:100%;max-width:100%;max-height:100%;margin:0;border-radius:10px 10px 0 0;height:100vh;resize:none} + .mp-header{padding:8px 14px} + .mp-body{padding:8px} + .mp-footer{padding:8px 14px;flex-wrap:wrap;gap:6px} + .mp-search-input{width:150px} + } + `; + document.head.appendChild(style); +} + +function setupModalDrag(modal, overlay, header) { + modal.style.position = 'absolute'; + modal.style.left = '50%'; + modal.style.top = '50%'; + modal.style.transform = 'translate(-50%, -50%)'; + + let dragging = false, sx = 0, sy = 0, sl = 0, st = 0; + + function onDown(e) { + if (!(e instanceof PointerEvent) || e.button !== 0) return; + dragging = true; + const overlayRect = overlay.getBoundingClientRect(); + const rect = modal.getBoundingClientRect(); + modal.style.left = (rect.left - overlayRect.left) + 'px'; + modal.style.top = (rect.top - overlayRect.top) + 'px'; + modal.style.transform = ''; + sx = e.clientX; sy = e.clientY; + sl = parseFloat(modal.style.left) || 0; + st = parseFloat(modal.style.top) || 0; + window.addEventListener('pointermove', onMove, { passive: true }); + window.addEventListener('pointerup', onUp, { once: true }); + e.preventDefault(); + } + + function onMove(e) { + if (!dragging) return; + const dx = e.clientX - sx, dy = e.clientY - sy; + let nl = sl + dx, nt = st + dy; + const maxLeft = (overlay.clientWidth || overlay.getBoundingClientRect().width) - modal.offsetWidth; + const maxTop = (overlay.clientHeight || overlay.getBoundingClientRect().height) - modal.offsetHeight; + nl = Math.max(0, Math.min(maxLeft, nl)); + nt = Math.max(0, Math.min(maxTop, nt)); + modal.style.left = nl + 'px'; + modal.style.top = nt + 'px'; + } + + function onUp() { + dragging = false; + window.removeEventListener('pointermove', onMove); + } + + header.addEventListener('pointerdown', onDown); +} + +function createMovableModal(title, content) { + injectPreviewModalStyles(); + const overlay = document.createElement('div'); + overlay.className = 'mp-overlay'; + const modal = document.createElement('div'); + modal.className = 'mp-modal'; + const header = document.createElement('div'); + header.className = 'mp-header'; + header.innerHTML = `${title}`; + const body = document.createElement('div'); + body.className = 'mp-body'; + body.innerHTML = content; + const footer = document.createElement('div'); + footer.className = 'mp-footer'; + footer.innerHTML = ` + + + + + + + + `; + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(footer); + overlay.appendChild(modal); + setupModalDrag(modal, overlay, header); + + let searchResults = []; + let currentIndex = -1; + const searchInput = footer.querySelector('.mp-search-input'); + const searchInfo = footer.querySelector('#mp-search-info'); + const prevBtn = footer.querySelector('#mp-search-prev'); + const nextBtn = footer.querySelector('#mp-search-next'); + + function clearHighlights() { body.querySelectorAll('.mp-highlight').forEach(el => { el.outerHTML = el.innerHTML; }); } + function performSearch(query) { + clearHighlights(); + searchResults = []; + currentIndex = -1; + if (!query.trim()) { searchInfo.textContent = ''; return; } + const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null, false); + const nodes = []; + let node; + while (node = walker.nextNode()) { nodes.push(node); } + const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + nodes.forEach(textNode => { + const text = textNode.textContent; + if (!text || !regex.test(text)) return; + let html = text; + let offset = 0; + regex.lastIndex = 0; + const matches = [...text.matchAll(regex)]; + matches.forEach((m) => { + const start = m.index + offset; + const end = start + m[0].length; + const before = html.slice(0, start); + const mid = html.slice(start, end); + const after = html.slice(end); + const span = `${mid}`; + html = before + span + after; + offset += span.length - m[0].length; + searchResults.push({}); + }); + const parent = textNode.parentElement; + parent.innerHTML = parent.innerHTML.replace(text, html); + }); + updateSearchInfo(); + if (searchResults.length > 0) { currentIndex = 0; highlightCurrent(); } + } + function updateSearchInfo() { if (!searchResults.length) searchInfo.textContent = searchInput.value.trim() ? '无结果' : ''; else searchInfo.textContent = `${currentIndex + 1}/${searchResults.length}`; } + function highlightCurrent() { + body.querySelectorAll('.mp-highlight.current').forEach(el => el.classList.remove('current')); + if (currentIndex >= 0 && currentIndex < searchResults.length) { + const el = body.querySelector(`.mp-highlight[data-search-index="${currentIndex}"]`); + if (el) { el.classList.add('current'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } + } + } + function navigateSearch(direction) { + if (!searchResults.length) return; + if (direction === 'next') currentIndex = (currentIndex + 1) % searchResults.length; + else currentIndex = currentIndex <= 0 ? searchResults.length - 1 : currentIndex - 1; + updateSearchInfo(); + highlightCurrent(); + } + let searchTimeout; + searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => performSearch(e.target.value), 250); }); + searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) navigateSearch('prev'); else navigateSearch('next'); } else if (e.key === 'Escape') { searchInput.value = ''; performSearch(''); } }); + prevBtn.addEventListener('click', () => navigateSearch('prev')); + nextBtn.addEventListener('click', () => navigateSearch('next')); + footer.querySelector('#mp-focus-search')?.addEventListener('click', () => { searchInput.focus(); if (searchInput.value) navigateSearch('next'); }); + + const close = () => overlay.remove(); + header.querySelector('.mp-close').addEventListener('click', close); + footer.querySelector('#mp-close').addEventListener('click', close); + footer.querySelector('#mp-toggle-format').addEventListener('click', (e) => { + const box = body.querySelector(".message-preview-content-box"); + const f = box?.querySelector(".mp-state-formatted"); + const r = box?.querySelector(".mp-state-raw"); + if (!(f && r)) return; + const showRaw = r.style.display === "none"; + r.style.display = showRaw ? "block" : "none"; + f.style.display = showRaw ? "none" : "block"; + e.currentTarget.textContent = showRaw ? "切换整理格式" : "切换原始格式"; + searchInput.value = ""; + clearHighlights(); + searchInfo.textContent = ""; + searchResults = []; + currentIndex = -1; + }); + + document.body.appendChild(overlay); + return { overlay, modal, body, close }; +} + +const MIRROR = { MERGE: "merge", MERGE_TOOLS: "merge_tools", SEMI: "semi", SEMI_TOOLS: "semi_tools", STRICT: "strict", STRICT_TOOLS: "strict_tools", SINGLE: "single" }; +const roleMap = { system: { label: "SYSTEM:", color: "#F7E3DA" }, user: { label: "USER:", color: "#F0ADA7" }, assistant: { label: "ASSISTANT:", color: "#6BB2CC" } }; +const colorXml = (t) => (typeof t === "string" ? t.replace(/<([^>]+)>/g, '<$1>') : t); +const getNames = (req) => { const n = { charName: String(req?.char_name || ""), userName: String(req?.user_name || ""), groupNames: Array.isArray(req?.group_names) ? req.group_names.map(String) : [] }; n.startsWithGroupName = (m) => n.groupNames.some((g) => String(m || "").startsWith(`${g}: `)); return n; }; +const toText = (m) => { const c = m?.content; if (typeof c === "string") return c; if (Array.isArray(c)) return c.map((p) => p?.type === "text" ? String(p.text || "") : p?.type === "image_url" ? "[image]" : p?.type === "video_url" ? "[video]" : typeof p === "string" ? p : (typeof p?.content === "string" ? p.content : "")).filter(Boolean).join("\n\n"); return String(c || ""); }; +const applyName = (m, n) => { const { role, name } = m; let t = toText(m); if (role === "system" && name === "example_assistant") { if (n.charName && !t.startsWith(`${n.charName}: `) && !n.startsWithGroupName(t)) t = `${n.charName}: ${t}`; } else if (role === "system" && name === "example_user") { if (n.userName && !t.startsWith(`${n.userName}: `)) t = `${n.userName}: ${t}`; } else if (name && role !== "system" && !t.startsWith(`${name}: `)) t = `${name}: ${t}`; return { ...m, content: t, name: undefined }; }; +function mergeMessages(messages, names, { strict = false, placeholders = false, single = false, tools = false } = {}) { + if (!Array.isArray(messages)) return []; + let mapped = messages.map((m) => applyName({ ...m }, names)).map((x) => { const m = { ...x }; if (!tools) { if (m.role === "tool") m.role = "user"; delete m.tool_calls; delete m.tool_call_id; } if (single) { if (m.role === "assistant") { const t = String(m.content || ""); if (names.charName && !t.startsWith(`${names.charName}: `) && !names.startsWithGroupName(t)) m.content = `${names.charName}: ${t}`; } if (m.role === "user") { const t = String(m.content || ""); if (names.userName && !t.startsWith(`${names.userName}: `)) m.content = `${names.userName}: ${t}`; } m.role = "user"; } return m; }); + const squash = (arr) => { const out = []; for (const m of arr) { if (out.length && out[out.length - 1].role === m.role && String(m.content || "").length && m.role !== "tool") out[out.length - 1].content += `\n\n${m.content}`; else out.push(m); } return out; }; + let sq = squash(mapped); + if (strict) { for (let i = 0; i < sq.length; i++) if (i > 0 && sq[i].role === "system") sq[i].role = "user"; if (placeholders) { if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); else if (sq[0].role === "system" && (sq.length === 1 || sq[1].role !== "user")) sq.splice(1, 0, { role: "user", content: "[Start a new chat]" }); else if (sq[0].role !== "system" && sq[0].role !== "user") sq.unshift({ role: "user", content: "[Start a new chat]" }); } return squash(sq); } + if (!sq.length) sq.push({ role: "user", content: "[Start a new chat]" }); + return sq; +} +function mirror(requestData) { + try { + let type = String(requestData?.custom_prompt_post_processing || "").toLowerCase(); + const source = String(requestData?.chat_completion_source || "").toLowerCase(); + if (source === "perplexity") type = MIRROR.STRICT; + const names = getNames(requestData || {}), src = Array.isArray(requestData?.messages) ? JSON.parse(JSON.stringify(requestData.messages)) : []; + const mk = (o) => mergeMessages(src, names, o); + switch (type) { + case MIRROR.MERGE: return mk({ strict: false }); + case MIRROR.MERGE_TOOLS: return mk({ strict: false, tools: true }); + case MIRROR.SEMI: return mk({ strict: true }); + case MIRROR.SEMI_TOOLS: return mk({ strict: true, tools: true }); + case MIRROR.STRICT: return mk({ strict: true, placeholders: true }); + case MIRROR.STRICT_TOOLS: return mk({ strict: true, placeholders: true, tools: true }); + case MIRROR.SINGLE: return mk({ strict: true, single: true }); + default: return src; + } + } catch { return Array.isArray(requestData?.messages) ? requestData.messages : []; } +} +const finalMsgs = (d) => { try { if (d?.requestData?.messages) return mirror(d.requestData); if (Array.isArray(d?.messages)) return d.messages; return []; } catch { return Array.isArray(d?.messages) ? d.messages : []; } }; +const formatPreview = (d) => { + const msgs = finalMsgs(d); + let out = `↓酒馆日志↓(${msgs.length})\n${"-".repeat(30)}\n`; + msgs.forEach((m, i) => { + const txt = m.content || ""; + const rm = roleMap[m.role] || { label: `${String(m.role || "").toUpperCase()}:`, color: "#FFF" }; + out += `
${rm.label}
`; + out += /<[^>]+>/g.test(txt) ? `
${colorXml(txt)}
` : `
${txt}
`; + }); + return out; +}; +const stripTop = (o) => { try { if (!o || typeof o !== "object") return o; if (Array.isArray(o)) return o; const messages = Array.isArray(o.messages) ? JSON.parse(JSON.stringify(o.messages)) : undefined; return typeof messages !== "undefined" ? { messages } : {}; } catch { return {}; } }; +const formatRaw = (d) => { try { const hasReq = Array.isArray(d?.requestData?.messages), hasMsgs = !hasReq && Array.isArray(d?.messages); let obj; if (hasReq) { const req = JSON.parse(JSON.stringify(d.requestData)); try { req.messages = mirror(req); } catch { } obj = req; } else if (hasMsgs) { const fake = { ...(d || {}), messages: d.messages }; let mm = null; try { mm = mirror(fake); } catch { } obj = { ...(d || {}), messages: mm || d.messages }; } else obj = d?.requestData ?? d; obj = stripTop(obj); return colorXml(JSON.stringify(obj, null, 2)); } catch { try { return colorXml(String(d)); } catch { return ""; } } }; +const buildPreviewHtml = (d) => { const formatted = formatPreview(d), raw = formatRaw(d); return `
${formatted}
`; }; +const openPopup = async (html, title) => { createMovableModal(title, html); }; +const displayPreview = async (d) => { try { await openPopup(buildPreviewHtml(d), "消息拦截"); } catch { toastr.error("显示拦截失败"); } }; + +const pushHistory = (r) => { S.history.unshift(r); if (S.history.length > C.MAX_HISTORY) S.history.length = C.MAX_HISTORY; }; +const extractUser = (ms) => { if (!Array.isArray(ms)) return ""; for (let i = ms.length - 1; i >= 0; i--) if (ms[i]?.role === "user") return ms[i].content || ""; return ""; }; + +async function recordReal(input, options) { + try { + const url = input instanceof Request ? input.url : input; + const body = await safeReadBodyFromInput(input, options); + if (!body) return; + const data = safeJson(body) || {}, ctx = getContext(); + pushHistory({ url, method: options?.method || (input instanceof Request ? input.method : "POST"), requestData: data, messages: data.messages || [], model: data.model || "Unknown", timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", userInput: extractUser(data.messages || []), isRealRequest: true }); + setTimeout(() => { if (S.history[0] && !S.history[0].associatedMessageId) S.history[0].associatedMessageId = ctx.chat?.length || 0; }, C.ASSOC_DELAY); + } catch { } +} + +const findRec = (id) => { + if (!S.history.length) return null; + const preds = [(r) => r.associatedMessageId === id, (r) => r.messageId === id, (r) => r.messageId === id - 1, (r) => Math.abs(r.messageId - id) <= 1]; + for (const p of preds) { const m = S.history.find(p); if (m) return m; } + const cs = S.history.filter((r) => r.messageId <= id + 2); + return cs.length ? cs.sort((a, b) => b.messageId - a.messageId)[0] : S.history[0]; +}; + +// Improved purgePreviewArtifacts - follows SillyTavern's batch delete pattern +async function purgePreviewArtifacts() { + try { + if (!S.pendingPurge) return; + S.pendingPurge = false; + const ctx = getContext(); + const chat = Array.isArray(ctx.chat) ? ctx.chat : []; + const start = Math.max(0, Number(S.chatLenBefore) || 0); + if (start >= chat.length) return; + + // 1. Remove DOM elements (following SillyTavern's pattern from #dialogue_del_mes_ok) + const $chat = $('#chat'); + $chat.find(`.mes[mesid="${start}"]`).nextAll('.mes').addBack().remove(); + + // 2. Truncate chat array + chat.length = start; + + // 3. Update last_mes class + $('#chat .mes').removeClass('last_mes'); + $('#chat .mes').last().addClass('last_mes'); + + // 4. Save chat and emit MESSAGE_DELETED event (critical for other plugins) + ctx.saveChat?.(); + await eventSource.emit(event_types.MESSAGE_DELETED, start); + } catch (e) { + console.error('[message-preview] purgePreviewArtifacts error', e); + } +} + + + +function oneShotOnLast(ev, handler) { + const wrapped = (...args) => { + try { handler(...args); } finally { off(); } + }; + let off = () => { }; + if (typeof eventSource.makeLast === "function") { + eventSource.makeLast(ev, wrapped); + off = () => { + try { eventSource.removeListener?.(ev, wrapped); } catch { } + try { eventSource.off?.(ev, wrapped); } catch { } + }; + } else if (S.tailAPI?.onLast) { + const disposer = S.tailAPI.onLast(ev, wrapped); + off = () => { try { disposer?.(); } catch { } }; + } else { + eventSource.on(ev, wrapped); + off = () => { try { eventSource.removeListener?.(ev, wrapped); } catch { } }; + } + return off; +} + +function installEventSourceTail(es) { + if (!es || es.__lw_tailInstalled) return es?.__lw_tailAPI || null; + const SYM = { MW_STACK: Symbol.for("lwbox.es.emitMiddlewareStack"), BASE: Symbol.for("lwbox.es.emitBase"), ORIG_DESC: Symbol.for("lwbox.es.emit.origDesc"), COMPOSED: Symbol.for("lwbox.es.emit.composed"), ID: Symbol.for("lwbox.middleware.identity") }; + const getFnFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(es); if (typeof v === "function") return v; } } catch { } return es.emit?.bind?.(es) || es.emit; }; + const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base); + const tails = new Map(); + const addTail = (ev, fn) => { if (typeof fn !== "function") return () => { }; const arr = tails.get(ev) || []; arr.push(fn); tails.set(ev, arr); return () => { const a = tails.get(ev); if (!a) return; const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); }; }; + const runTails = (ev, args) => { const arr = tails.get(ev); if (!arr?.length) return; for (const h of arr.slice()) { try { h(...args); } catch (e) { } } }; + const makeTailMw = () => { const mw = (next) => function patchedEmit(ev, ...args) { let r; try { r = next.call(this, ev, ...args); } catch (e) { queueMicrotask(() => runTails(ev, args)); throw e; } if (r && typeof r.then === "function") r.finally(() => runTails(ev, args)); else queueMicrotask(() => runTails(ev, args)); return r; }; Object.defineProperty(mw, SYM.ID, { value: true }); return Object.freeze(mw); }; + const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(es, "emit"); if (!es[SYM.ORIG_DESC]) es[SYM.ORIG_DESC] = d || null; es[SYM.BASE] ||= getFnFromDesc(d); Object.defineProperty(es, "emit", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { es[SYM.BASE] = v; queueMicrotask(reapply); } } }); } catch { } }; + const reapply = () => { try { const base = es[SYM.BASE] || getFnFromDesc(Object.getOwnPropertyDescriptor(es, "emit")) || es.emit.bind(es); const stack = es[SYM.MW_STACK] || (es[SYM.MW_STACK] = []); let idx = stack.findIndex((m) => m && m[SYM.ID]); if (idx === -1) { stack.push(makeTailMw()); idx = stack.length - 1; } if (idx !== stack.length - 1) { const mw = stack[idx]; stack.splice(idx, 1); stack.push(mw); } const composed = compose(base, stack) || base; if (!es[SYM.COMPOSED] || es[SYM.COMPOSED]._base !== base || es[SYM.COMPOSED]._stack !== stack) { composed._base = base; composed._stack = stack; es[SYM.COMPOSED] = composed; } return es[SYM.COMPOSED]; } catch { return es.emit; } }; + ensureAccessor(); + queueMicrotask(reapply); + const api = { onLast: (e, h) => addTail(e, h), removeLast: (e, h) => { const a = tails.get(e); if (!a) return; const i = a.indexOf(h); if (i >= 0) a.splice(i, 1); }, uninstall() { try { const s = es[SYM.MW_STACK]; const i = Array.isArray(s) ? s.findIndex((m) => m && m[SYM.ID]) : -1; if (i >= 0) s.splice(i, 1); const orig = es[SYM.ORIG_DESC]; if (orig) { try { Object.defineProperty(es, "emit", orig); } catch { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } else { Object.defineProperty(es, "emit", { configurable: true, enumerable: true, writable: true, value: es[SYM.BASE] || es.emit }); } } catch { } delete es.__lw_tailInstalled; delete es.__lw_tailAPI; tails.clear(); } }; + Object.defineProperty(es, "__lw_tailInstalled", { value: true }); + Object.defineProperty(es, "__lw_tailAPI", { value: api }); + return api; +} + +let __installed = false; +const MW_KEY = Symbol.for("lwbox.fetchMiddlewareStack"); +const BASE_KEY = Symbol.for("lwbox.fetchBase"); +const ORIG_KEY = Symbol.for("lwbox.fetch.origDesc"); +const CMP_KEY = Symbol.for("lwbox.fetch.composed"); +const ID = Symbol.for("lwbox.middleware.identity"); +const getFetchFromDesc = (d) => { try { if (typeof d?.value === "function") return d.value; if (typeof d?.get === "function") { const v = d.get.call(window); if (typeof v === "function") return v; } } catch { } return globalThis.fetch; }; +const compose = (base, stack) => stack.reduce((acc, mw) => mw(acc), base); +const withTimeout = (p, ms = 200) => { try { return Promise.race([p, new Promise((r) => setTimeout(r, ms))]); } catch { return p; } }; +const ensureAccessor = () => { try { const d = Object.getOwnPropertyDescriptor(window, "fetch"); if (!window[ORIG_KEY]) window[ORIG_KEY] = d || null; window[BASE_KEY] ||= getFetchFromDesc(d); Object.defineProperty(window, "fetch", { configurable: true, enumerable: d?.enumerable ?? true, get() { return reapply(); }, set(v) { if (typeof v === "function") { window[BASE_KEY] = v; queueMicrotask(reapply); } } }); } catch { } }; +const reapply = () => { try { const base = window[BASE_KEY] || getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); const stack = window[MW_KEY] || (window[MW_KEY] = []); let idx = stack.findIndex((m) => m && m[ID]); if (idx === -1) { stack.push(makeMw()); idx = stack.length - 1; } if (idx !== window[MW_KEY].length - 1) { const mw = window[MW_KEY][idx]; window[MW_KEY].splice(idx, 1); window[MW_KEY].push(mw); } const composed = compose(base, stack) || base; if (!window[CMP_KEY] || window[CMP_KEY]._base !== base || window[CMP_KEY]._stack !== stack) { composed._base = base; composed._stack = stack; window[CMP_KEY] = composed; } return window[CMP_KEY]; } catch { return globalThis.fetch; } }; +function makeMw() { + const mw = (next) => async function f(input, options = {}) { + try { + if (await isTarget(input, options)) { + if (S.isPreview || S.isLong) { + const url = input instanceof Request ? input.url : input; + return interceptPreview(url, options).catch(() => new Response(JSON.stringify({ error: { message: "拦截失败,请手动中止消息生成。" } }), { status: 200, headers: { "Content-Type": "application/json" } })); + } else { try { await withTimeout(recordReal(input, options)); } catch { } } + } + } catch { } + return Reflect.apply(next, this, arguments); + }; + Object.defineProperty(mw, ID, { value: true, enumerable: false }); + return Object.freeze(mw); +} +function installFetch() { + if (__installed) return; __installed = true; + try { + window[MW_KEY] ||= []; + window[BASE_KEY] ||= getFetchFromDesc(Object.getOwnPropertyDescriptor(window, "fetch")); + ensureAccessor(); + if (!window[MW_KEY].some((m) => m && m[ID])) window[MW_KEY].push(makeMw()); + else { + const i = window[MW_KEY].findIndex((m) => m && m[ID]); + if (i !== window[MW_KEY].length - 1) { + const mw = window[MW_KEY][i]; + window[MW_KEY].splice(i, 1); + window[MW_KEY].push(mw); + } + } + queueMicrotask(reapply); + window.addEventListener("pageshow", reapply, { passive: true }); + document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reapply(); }, { passive: true }); + window.addEventListener("focus", reapply, { passive: true }); + } catch { } +} +function uninstallFetch() { + if (!__installed) return; + try { + const s = window[MW_KEY]; + const i = Array.isArray(s) ? s.findIndex((m) => m && m[ID]) : -1; + if (i >= 0) s.splice(i, 1); + const others = Array.isArray(window[MW_KEY]) && window[MW_KEY].length; + const orig = window[ORIG_KEY]; + if (!others) { + if (orig) { + try { Object.defineProperty(window, "fetch", orig); } + catch { Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); } + } else { + Object.defineProperty(window, "fetch", { configurable: true, enumerable: true, writable: true, value: window[BASE_KEY] || globalThis.fetch }); + } + } else { + reapply(); + } + } catch { } + __installed = false; +} +const setupFetch = () => { if (!S.active) { installFetch(); S.active = true; } }; +const restoreFetch = () => { if (S.active) { uninstallFetch(); S.active = false; } }; +const updateFetchState = () => { const st = getSettings(), need = (st.preview.enabled || st.recorded.enabled); if (need && !S.active) setupFetch(); if (!need && S.active) restoreFetch(); }; + +async function interceptPreview(url, options) { + const body = await safeReadBodyFromInput(url, options); + const data = safeJson(body) || {}; + const userInput = extractUser(data?.messages || []); + const ctx = getContext(); + + if (S.isLong) { + const chat = Array.isArray(ctx.chat) ? ctx.chat : []; + let start = chat.length; + if (chat.length > 0 && chat[chat.length - 1]?.is_user === true) start = chat.length - 1; + S.chatLenBefore = start; + S.pendingPurge = true; + oneShotOnLast(event_types.GENERATION_ENDED, () => setTimeout(() => purgePreviewArtifacts(), 0)); + } + + S.previewData = { url, method: options?.method || "POST", requestData: data, messages: data?.messages || [], model: data?.model || "Unknown", timestamp: now(), userInput, isPreview: true }; + if (S.isLong) { setTimeout(() => { displayPreview(S.previewData); }, 100); } else if (S.resolve) { S.resolve({ success: true, data: S.previewData }); S.resolve = S.reject = null; } + const payload = S.isLong ? { choices: [{ message: { content: "【小白X】已拦截消息" }, finish_reason: "stop" }], intercepted: true } : { choices: [{ message: { content: "" }, finish_reason: "stop" }] }; + return new Response(JSON.stringify(payload), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +const addHistoryButtonsDebounced = debounce(() => { + const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return; + $(".mes_history_preview").remove(); + $("#chat .mes").each(function () { + const id = parseInt($(this).attr("mesid")), isUser = $(this).attr("is_user") === "true"; + if (id <= 0 || isUser) return; + const btn = $(`
`).on("click", (e) => { e.preventDefault(); e.stopPropagation(); showHistoryPreview(id); }); + if (window.registerButtonToSubContainer && window.registerButtonToSubContainer(id, btn[0])) return; + $(this).find(".flex-container.flex1.alignitemscenter").append(btn); + }); +}, C.DEBOUNCE); + +const disableSend = (dis = true) => { + const $b = $q("#send_but"); + if (dis) { S.sendBtnWasDisabled = $b.prop("disabled"); $b.prop("disabled", true).off("click.preview-block").on("click.preview-block", (e) => { e.preventDefault(); e.stopImmediatePropagation(); return false; }); } + else { $b.prop("disabled", S.sendBtnWasDisabled).off("click.preview-block"); S.sendBtnWasDisabled = false; } +}; +const triggerSend = () => { + const $b = $q("#send_but"), $t = $q("#send_textarea"), txt = String($t.val() || ""); if (!txt.trim()) return false; + const was = $b.prop("disabled"); $b.prop("disabled", false); $b[0].dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); if (was) $b.prop("disabled", true); return true; +}; + +async function showPreview() { + let toast = null, backup = null; + try { + const set = getSettings(); if (!set.preview.enabled || !geEnabled()) return toastr.warning("消息拦截功能未启用"); + const text = String($q("#send_textarea").val() || "").trim(); if (!text) return toastr.error("请先输入消息内容"); + + backup = text; disableSend(true); + const ctx = getContext(); + S.chatLenBefore = Array.isArray(ctx.chat) ? ctx.chat.length : 0; + S.isPreview = true; S.previewData = null; S.previewIds.clear(); S.previewAbort = new AbortController(); + S.pendingPurge = true; + + const endHandler = () => { + try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { } + if (S.pendingPurge) { + setTimeout(() => purgePreviewArtifacts(), 0); + } + }; + + S.genEndedOff = oneShotOnLast(event_types.GENERATION_ENDED, endHandler); + clearTimeout(S.cleanupFallback); + S.cleanupFallback = setTimeout(() => { + try { if (S.genEndedOff) { S.genEndedOff(); S.genEndedOff = null; } } catch { } + purgePreviewArtifacts(); + }, 1500); + + toast = toastr.info(`正在拦截请求...(${set.preview.timeoutSeconds}秒超时)`, "消息拦截", { timeOut: 0, tapToDismiss: false }); + + if (!triggerSend()) throw new Error("无法触发发送事件"); + + const res = await waitIntercept().catch((e) => ({ success: false, error: e?.message || e })); + if (toast) { toastr.clear(toast); toast = null; } + if (res.success) { await displayPreview(res.data); toastr.success("拦截成功!", "", { timeOut: 3000 }); } + else toastr.error(`拦截失败: ${res.error}`, "", { timeOut: 5000 }); + } catch (e) { + if (toast) toastr.clear(toast); toastr.error(`拦截异常: ${e.message}`, "", { timeOut: 5000 }); + } finally { + try { S.previewAbort?.abort("拦截结束"); } catch { } S.previewAbort = null; + if (S.resolve) S.resolve({ success: false, error: "拦截已取消" }); S.resolve = S.reject = null; + clearTimeout(S.cleanupFallback); S.cleanupFallback = null; + S.isPreview = false; S.previewData = null; + disableSend(false); if (backup) $q("#send_textarea").val(backup); + } +} + +async function showHistoryPreview(messageId) { + try { + const set = getSettings(); if (!set.recorded.enabled || !geEnabled()) return; + const rec = findRec(messageId); + if (rec?.messages?.length || rec?.requestData?.messages?.length) await openPopup(buildPreviewHtml({ ...rec, isHistoryPreview: true, targetMessageId: messageId }), `消息历史查看 - 第 ${messageId + 1} 条消息`); + else toastr.warning(`未找到第 ${messageId + 1} 条消息的API请求记录`); + } catch { toastr.error("查看历史消息失败"); } +} + +const cleanupMemory = () => { + if (S.history.length > C.MAX_HISTORY) S.history = S.history.slice(0, C.MAX_HISTORY); + S.previewIds.clear(); S.previewData = null; $(".mes_history_preview").each(function () { if (!$(this).closest(".mes").length) $(this).remove(); }); + if (!S.isLong) S.interceptedIds = []; +}; + +function onLast(ev, handler) { + if (typeof eventSource.makeLast === "function") { eventSource.makeLast(ev, handler); S.listeners.push({ e: ev, h: handler, off: () => { } }); return; } + if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(ev, handler); S.listeners.push({ e: ev, h: handler, off }); return; } + const tail = (...args) => queueMicrotask(() => { try { handler(...args); } catch { } }); + eventSource.on(ev, tail); + S.listeners.push({ e: ev, h: tail, off: () => eventSource.removeListener?.(ev, tail) }); +} + +const addEvents = () => { + removeEvents(); + [ + { e: event_types.MESSAGE_RECEIVED, h: addHistoryButtonsDebounced }, + { e: event_types.CHARACTER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced }, + { e: event_types.USER_MESSAGE_RENDERED, h: addHistoryButtonsDebounced }, + { e: event_types.CHAT_CHANGED, h: () => { S.history = []; setTimeout(addHistoryButtonsDebounced, C.CHECK); } }, + { e: event_types.MESSAGE_RECEIVED, h: (messageId) => setTimeout(() => { const r = S.history.find((x) => !x.associatedMessageId && now() - x.timestamp < C.REQ_WINDOW); if (r) r.associatedMessageId = messageId; }, 100) }, + ].forEach(({ e, h }) => onLast(e, h)); + const late = (payload) => { + try { + const ctx = getContext(); + pushHistory({ + url: C.TARGET, method: "POST", requestData: payload, messages: payload?.messages || [], model: payload?.model || "Unknown", + timestamp: now(), messageId: ctx.chat?.length || 0, characterName: ctx.characters?.[ctx.characterId]?.name || "Unknown", + userInput: extractUser(payload?.messages || []), isRealRequest: true, source: "settings_ready", + }); + } catch { } + queueMicrotask(() => updateFetchState()); + }; + if (typeof eventSource.makeLast === "function") { eventSource.makeLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => { } }); } + else if (S.tailAPI?.onLast) { const off = S.tailAPI.onLast(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off }); } + else { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); S.listeners.push({ e: event_types.CHAT_COMPLETION_SETTINGS_READY, h: late, off: () => OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late) }); queueMicrotask(() => { try { OFF(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } try { ON(event_types.CHAT_COMPLETION_SETTINGS_READY, late); } catch { } }); } +}; +const removeEvents = () => { S.listeners.forEach(({ e, h, off }) => { if (typeof off === "function") { try { off(); } catch { } } else { try { OFF(e, h); } catch { } } }); S.listeners = []; }; + +const toggleLong = () => { + S.isLong = !S.isLong; + const $b = $q("#message_preview_btn"); + if (S.isLong) { + $b.css("color", "red"); + toastr.info("持续拦截已开启", "", { timeOut: 2000 }); + } else { + $b.css("color", ""); + S.pendingPurge = false; + toastr.info("持续拦截已关闭", "", { timeOut: 2000 }); + } +}; +const bindBtn = () => { + const $b = $q("#message_preview_btn"); + $b.on("mousedown touchstart", () => { S.longPressTimer = setTimeout(() => toggleLong(), S.longPressDelay); }); + $b.on("mouseup touchend mouseleave", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } }); + $b.on("click", () => { if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; return; } if (!S.isLong) showPreview(); }); +}; + +const waitIntercept = () => new Promise((resolve, reject) => { + const t = setTimeout(() => { if (S.resolve) { S.resolve({ success: false, error: `等待超时 (${getSettings().preview.timeoutSeconds}秒)` }); S.resolve = S.reject = null; } }, getSettings().preview.timeoutSeconds * 1000); + S.resolve = (v) => { clearTimeout(t); resolve(v); }; S.reject = (e) => { clearTimeout(t); reject(e); }; +}); + +function cleanup() { + removeEvents(); restoreFetch(); disableSend(false); + $(".mes_history_preview").remove(); $("#message_preview_btn").remove(); cleanupMemory(); + Object.assign(S, { resolve: null, reject: null, isPreview: false, isLong: false, interceptedIds: [], chatLenBefore: 0, sendBtnWasDisabled: false, pendingPurge: false }); + if (S.longPressTimer) { clearTimeout(S.longPressTimer); S.longPressTimer = null; } + if (S.restoreLong) { try { S.restoreLong(); } catch { } S.restoreLong = null; } + if (S.genEndedOff) { try { S.genEndedOff(); } catch { } S.genEndedOff = null; } + if (S.cleanupFallback) { clearTimeout(S.cleanupFallback); S.cleanupFallback = null; } +} + +function initMessagePreview() { + try { + cleanup(); S.tailAPI = installEventSourceTail(eventSource); + const set = getSettings(); + const btn = $(`
`); + $("#send_but").before(btn); bindBtn(); + $("#xiaobaix_preview_enabled").prop("checked", set.preview.enabled).on("change", function () { + if (!geEnabled()) return; set.preview.enabled = $(this).prop("checked"); saveSettingsDebounced(); + $("#message_preview_btn").toggle(set.preview.enabled); + if (set.preview.enabled) { if (!S.cleanTimer) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); } + else { if (S.cleanTimer) { clearInterval(S.cleanTimer); S.cleanTimer = null; } } + updateFetchState(); + if (!set.preview.enabled && set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); } + }); + $("#xiaobaix_recorded_enabled").prop("checked", set.recorded.enabled).on("change", function () { + if (!geEnabled()) return; set.recorded.enabled = $(this).prop("checked"); saveSettingsDebounced(); + if (set.recorded.enabled) { addEvents(); addHistoryButtonsDebounced(); } + else { $(".mes_history_preview").remove(); S.history.length = 0; if (!set.preview.enabled) removeEvents(); } + updateFetchState(); + }); + if (!set.preview.enabled) $("#message_preview_btn").hide(); + updateFetchState(); if (set.recorded.enabled) addHistoryButtonsDebounced(); + if (set.preview.enabled || set.recorded.enabled) addEvents(); + if (window.registerModuleCleanup) window.registerModuleCleanup("messagePreview", cleanup); + if (set.preview.enabled) S.cleanTimer = setInterval(cleanupMemory, C.CLEAN); + } catch { toastr.error("模块初始化失败"); } +} + +window.addEventListener("beforeunload", cleanup); +window.messagePreviewCleanup = cleanup; + export { initMessagePreview, addHistoryButtonsDebounced, cleanup }; \ No newline at end of file diff --git a/modules/scheduled-tasks/embedded-tasks.html b/modules/scheduled-tasks/embedded-tasks.html index 78cf81f..4bf0965 100644 --- a/modules/scheduled-tasks/embedded-tasks.html +++ b/modules/scheduled-tasks/embedded-tasks.html @@ -1,75 +1,75 @@ -
-

检测到嵌入的循环任务及可能的统计好感度设定

-

此角色包含循环任务,这些任务可能会自动执行斜杠命令。

-

您是否允许此角色使用这些任务?

-
- - 注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。 -
-
- - +
+

检测到嵌入的循环任务及可能的统计好感度设定

+

此角色包含循环任务,这些任务可能会自动执行斜杠命令。

+

您是否允许此角色使用这些任务?

+
+ + 注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。 +
+
+ + diff --git a/modules/scheduled-tasks/scheduled-tasks.html b/modules/scheduled-tasks/scheduled-tasks.html index 78cf81f..4bf0965 100644 --- a/modules/scheduled-tasks/scheduled-tasks.html +++ b/modules/scheduled-tasks/scheduled-tasks.html @@ -1,75 +1,75 @@ -
-

检测到嵌入的循环任务及可能的统计好感度设定

-

此角色包含循环任务,这些任务可能会自动执行斜杠命令。

-

您是否允许此角色使用这些任务?

-
- - 注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。 -
-
- - +
+

检测到嵌入的循环任务及可能的统计好感度设定

+

此角色包含循环任务,这些任务可能会自动执行斜杠命令。

+

您是否允许此角色使用这些任务?

+
+ + 注意:这些任务将在对话过程中自动执行,请确认您信任此角色的创建者。 +
+
+ + diff --git a/modules/scheduled-tasks/scheduled-tasks.js b/modules/scheduled-tasks/scheduled-tasks.js index 89a8afa..34975c5 100644 --- a/modules/scheduled-tasks/scheduled-tasks.js +++ b/modules/scheduled-tasks/scheduled-tasks.js @@ -16,6 +16,7 @@ import { executeSlashCommand } from "../../core/slash-command.js"; import { EXT_ID } from "../../core/constants.js"; import { createModuleEvents, event_types } from "../../core/event-manager.js"; import { xbLog, CacheRegistry } from "../../core/debug-core.js"; +import { TasksStorage } from "../../core/server-storage.js"; // ═══════════════════════════════════════════════════════════════════════════ // 常量和默认值 @@ -27,80 +28,72 @@ const CONFIG = { MAX_PROCESSED: 20, MAX_COOLDOWN: 10, CLEANUP_INTERVAL: 30000, T const events = createModuleEvents('scheduledTasks'); // ═══════════════════════════════════════════════════════════════════════════ -// IndexedDB 脚本存储 +// 数据迁移 // ═══════════════════════════════════════════════════════════════════════════ -const TaskScriptDB = { - dbName: 'LittleWhiteBox_TaskScripts', - storeName: 'scripts', - _db: null, - _cache: new Map(), +async function migrateToServerStorage() { + const FLAG = 'LWB_tasks_migrated_server_v1'; + if (localStorage.getItem(FLAG)) return; - async open() { - if (this._db) return this._db; - return new Promise((resolve, reject) => { - const request = indexedDB.open(this.dbName, 1); - request.onerror = () => reject(request.error); - request.onsuccess = () => { this._db = request.result; resolve(this._db); }; - request.onupgradeneeded = (e) => { - const db = e.target.result; - if (!db.objectStoreNames.contains(this.storeName)) { - db.createObjectStore(this.storeName); - } - }; - }); - }, + let count = 0; - async get(taskId) { - if (!taskId) return ''; - if (this._cache.has(taskId)) return this._cache.get(taskId); - try { - const db = await this.open(); - return new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readonly'); - const request = tx.objectStore(this.storeName).get(taskId); - request.onerror = () => resolve(''); - request.onsuccess = () => { - const val = request.result || ''; - this._cache.set(taskId, val); - resolve(val); - }; - }); - } catch { return ''; } - }, - - async set(taskId, commands) { - if (!taskId) return; - this._cache.set(taskId, commands || ''); - try { - const db = await this.open(); - return new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite'); - tx.objectStore(this.storeName).put(commands || '', taskId); - tx.oncomplete = () => resolve(); - tx.onerror = () => resolve(); - }); - } catch {} - }, - - async delete(taskId) { - if (!taskId) return; - this._cache.delete(taskId); - try { - const db = await this.open(); - return new Promise((resolve) => { - const tx = db.transaction(this.storeName, 'readwrite'); - tx.objectStore(this.storeName).delete(taskId); - tx.oncomplete = () => resolve(); - tx.onerror = () => resolve(); - }); - } catch {} - }, - - clearCache() { - this._cache.clear(); + const settings = getSettings(); + for (const task of (settings.globalTasks || [])) { + if (!task) continue; + if (!task.id) task.id = uuidv4(); + if (task.commands) { + await TasksStorage.set(task.id, task.commands); + delete task.commands; + count++; + } } -}; + if (count > 0) saveSettingsDebounced(); + + await new Promise((resolve) => { + const req = indexedDB.open('LittleWhiteBox_TaskScripts'); + req.onerror = () => resolve(); + req.onsuccess = async (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains('scripts')) { + db.close(); + resolve(); + return; + } + try { + const tx = db.transaction('scripts', 'readonly'); + const store = tx.objectStore('scripts'); + const keys = await new Promise(r => { + const req = store.getAllKeys(); + req.onsuccess = () => r(req.result || []); + req.onerror = () => r([]); + }); + const vals = await new Promise(r => { + const req = store.getAll(); + req.onsuccess = () => r(req.result || []); + req.onerror = () => r([]); + }); + for (let i = 0; i < keys.length; i++) { + if (keys[i] && vals[i]) { + await TasksStorage.set(keys[i], vals[i]); + count++; + } + } + } catch (err) { + console.warn('[Tasks] IndexedDB 迁移出错:', err); + } + db.close(); + indexedDB.deleteDatabase('LittleWhiteBox_TaskScripts'); + resolve(); + }; + }); + + if (count > 0) { + await TasksStorage.saveNow(); + console.log(`[Tasks] 已迁移 ${count} 个脚本到服务器`); + } + + localStorage.setItem(FLAG, 'true'); +} // ═══════════════════════════════════════════════════════════════════════════ // 状态 @@ -144,7 +137,7 @@ async function allTasksFull() { const globalMeta = getSettings().globalTasks || []; const globalTasks = await Promise.all(globalMeta.map(async (task) => ({ ...task, - commands: await TaskScriptDB.get(task.id) + commands: await TasksStorage.get(task.id) }))); return [ ...globalTasks.map(mapTiming), @@ -156,7 +149,7 @@ async function allTasksFull() { async function getTaskWithCommands(task, scope) { if (!task) return task; if (scope === 'global' && task.id && task.commands === undefined) { - return { ...task, commands: await TaskScriptDB.get(task.id) }; + return { ...task, commands: await TasksStorage.get(task.id) }; } return task; } @@ -414,23 +407,22 @@ const getTaskListByScope = (scope) => { }; async function persistTaskListByScope(scope, tasks) { - if (scope === 'character') { - await saveCharacterTasks(tasks); - return; - } - if (scope === 'preset') { - await savePresetTasks(tasks); - return; - } - + if (scope === 'character') return await saveCharacterTasks(tasks); + if (scope === 'preset') return await savePresetTasks(tasks); + const metaOnly = []; for (const task of tasks) { - if (task.id) { - await TaskScriptDB.set(task.id, task.commands || ''); + if (!task) continue; + if (!task.id) task.id = uuidv4(); + + if (Object.prototype.hasOwnProperty.call(task, 'commands')) { + await TasksStorage.set(task.id, String(task.commands ?? '')); } + const { commands, ...meta } = task; metaOnly.push(meta); } + getSettings().globalTasks = metaOnly; saveSettingsDebounced(); } @@ -442,7 +434,7 @@ async function removeTaskByScope(scope, taskId, fallbackIndex = -1) { const task = list[idx]; if (scope === 'global' && task?.id) { - await TaskScriptDB.delete(task.id); + await TasksStorage.delete(task.id); } list.splice(idx, 1); @@ -463,7 +455,7 @@ CacheRegistry.register('scheduledTasks', { const b = state.taskLastExecutionTime?.size || 0; const c = state.dynamicCallbacks?.size || 0; const d = __taskRunMap.size || 0; - const e = TaskScriptDB._cache?.size || 0; + const e = TasksStorage.getCacheSize() || 0; return a + b + c + d + e; } catch { return 0; } }, @@ -489,7 +481,7 @@ CacheRegistry.register('scheduledTasks', { total += (entry?.timers?.size || 0) * 8; total += (entry?.intervals?.size || 0) * 8; }); - addMap(TaskScriptDB._cache, addStr); + total += TasksStorage.getCacheBytes(); return total; } catch { return 0; } }, @@ -497,7 +489,7 @@ CacheRegistry.register('scheduledTasks', { try { state.processedMessagesSet?.clear?.(); state.taskLastExecutionTime?.clear?.(); - TaskScriptDB.clearCache(); + TasksStorage.clearCache(); const s = getSettings(); if (s?.processedMessages) s.processedMessages = []; saveSettingsDebounced(); @@ -516,7 +508,7 @@ CacheRegistry.register('scheduledTasks', { cooldown: state.taskLastExecutionTime?.size || 0, dynamicCallbacks: state.dynamicCallbacks?.size || 0, runningSingleInstances: __taskRunMap.size || 0, - scriptCache: TaskScriptDB._cache?.size || 0, + scriptCache: TasksStorage.getCacheSize() || 0, }; } catch { return {}; } }, @@ -1024,7 +1016,7 @@ async function onChatChanged(chatId) { isCommandGenerated: false }); state.taskLastExecutionTime.clear(); - TaskScriptDB.clearCache(); + TasksStorage.clearCache(); requestAnimationFrame(() => { state.processedMessagesSet.clear(); @@ -1081,18 +1073,26 @@ function createTaskItemSimple(task, index, scope = 'global') { before_user: '用户前', any_message: '任意对话', initialization: '角色卡初始化', + character_init: '角色卡初始化', plugin_init: '插件初始化', only_this_floor: '仅该楼层', chat_changed: '切换聊天后' }[task.triggerTiming] || 'AI后'; let displayName; - if (task.interval === 0) displayName = `${task.name} (手动触发)`; - else if (task.triggerTiming === 'initialization' || task.triggerTiming === 'character_init') displayName = `${task.name} (角色卡初始化)`; - else if (task.triggerTiming === 'plugin_init') displayName = `${task.name} (插件初始化)`; - else if (task.triggerTiming === 'chat_changed') displayName = `${task.name} (切换聊天后)`; - else if (task.triggerTiming === 'only_this_floor') displayName = `${task.name} (仅第${task.interval}${floorTypeText})`; - else displayName = `${task.name} (每${task.interval}${floorTypeText}·${triggerTimingText})`; + if (task.interval === 0) { + displayName = `${task.name} (手动触发)`; + } else if (task.triggerTiming === 'initialization' || task.triggerTiming === 'character_init') { + displayName = `${task.name} (角色卡初始化)`; + } else if (task.triggerTiming === 'plugin_init') { + displayName = `${task.name} (插件初始化)`; + } else if (task.triggerTiming === 'chat_changed') { + displayName = `${task.name} (切换聊天后)`; + } else if (task.triggerTiming === 'only_this_floor') { + displayName = `${task.name} (仅第${task.interval}${floorTypeText})`; + } else { + displayName = `${task.name} (每${task.interval}${floorTypeText}·${triggerTimingText})`; + } const taskElement = $('#task_item_template').children().first().clone(); taskElement.attr({ id: task.id, 'data-index': index, 'data-type': taskType }); @@ -1293,7 +1293,7 @@ async function showTaskEditor(task = null, isEdit = false, scope = 'global') { const sourceList = getTaskListByScope(initialScope); if (task && scope === 'global' && task.id) { - task = { ...task, commands: await TaskScriptDB.get(task.id) }; + task = { ...task, commands: await TasksStorage.get(task.id) }; } state.currentEditingTask = task; @@ -1601,7 +1601,7 @@ async function showCloudTasksModal() { function createCloudTaskItem(taskInfo) { const item = $('#cloud_task_item_template').children().first().clone(); item.find('.cloud-task-name').text(taskInfo.name || '未命名任务'); - item.find('.cloud-task-intro').text(taskInfo.简介 || '无简介'); + item.find('.cloud-task-intro').text(taskInfo.简介 || taskInfo.intro || '无简介'); item.find('.cloud-task-download').on('click', async function () { $(this).prop('disabled', true).find('i').removeClass('fa-download').addClass('fa-spinner fa-spin'); try { @@ -1631,7 +1631,7 @@ async function exportGlobalTasks() { const tasks = await Promise.all(metaList.map(async (meta) => ({ ...meta, - commands: await TaskScriptDB.get(meta.id) + commands: await TasksStorage.get(meta.id) }))); const fileName = `global_tasks_${new Date().toISOString().split('T')[0]}.json`; @@ -1645,7 +1645,7 @@ async function exportSingleTask(index, scope) { let task = list[index]; if (scope === 'global' && task.id) { - task = { ...task, commands: await TaskScriptDB.get(task.id) }; + task = { ...task, commands: await TasksStorage.get(task.id) }; } const fileName = `${scope}_task_${task?.name || 'unnamed'}_${new Date().toISOString().split('T')[0]}.json`; @@ -1754,7 +1754,7 @@ function getMemoryUsage() { taskCooldowns: state.taskLastExecutionTime.size, globalTasks: getSettings().globalTasks.length, characterTasks: getCharacterTasks().length, - scriptCache: TaskScriptDB._cache.size, + scriptCache: TasksStorage.getCacheSize(), maxProcessedMessages: CONFIG.MAX_PROCESSED, maxCooldownEntries: CONFIG.MAX_COOLDOWN }; @@ -1792,7 +1792,7 @@ function cleanup() { state.cleanupTimer = null; } state.taskLastExecutionTime.clear(); - TaskScriptDB.clearCache(); + TasksStorage.clearCache(); try { if (state.dynamicCallbacks && state.dynamicCallbacks.size > 0) { @@ -1865,11 +1865,11 @@ function cleanup() { async function setCommands(name, commands, opts = {}) { const { mode = 'replace', scope = 'all' } = opts; const hit = find(name, scope); - if (!hit) throw new Error(`任务未找到: ${name}`); + if (!hit) throw new Error(`找不到任务: ${name}`); let old = hit.task.commands || ''; if (hit.scope === 'global' && hit.task.id) { - old = await TaskScriptDB.get(hit.task.id); + old = await TasksStorage.get(hit.task.id); } const body = String(commands ?? ''); @@ -1891,7 +1891,7 @@ function cleanup() { async function setProps(name, props, scope = 'all') { const hit = find(name, scope); - if (!hit) throw new Error(`任务未找到: ${name}`); + if (!hit) throw new Error(`找不到任务: ${name}`); Object.assign(hit.task, props || {}); await persistTaskListByScope(hit.scope, hit.list); refreshTaskLists(); @@ -1900,10 +1900,10 @@ function cleanup() { async function exec(name) { const hit = find(name, 'all'); - if (!hit) throw new Error(`任务未找到: ${name}`); + if (!hit) throw new Error(`找不到任务: ${name}`); let commands = hit.task.commands || ''; if (hit.scope === 'global' && hit.task.id) { - commands = await TaskScriptDB.get(hit.task.id); + commands = await TasksStorage.get(hit.task.id); } return await executeCommands(commands, hit.task.name); } @@ -1911,7 +1911,7 @@ function cleanup() { async function dump(scope = 'all') { const g = await Promise.all((getSettings().globalTasks || []).map(async t => ({ ...structuredClone(t), - commands: await TaskScriptDB.get(t.id) + commands: await TasksStorage.get(t.id) }))); const c = structuredClone(getCharacterTasks() || []); const p = structuredClone(getPresetTasks() || []); @@ -2078,37 +2078,7 @@ function registerSlashCommands() { helpString: `设置任务属性。用法: /xbset status=on/off interval=数字 timing=时机 floorType=类型 任务名` })); } catch (error) { - console.error("Error registering slash commands:", error); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 数据迁移 -// ═══════════════════════════════════════════════════════════════════════════ - -async function migrateGlobalTasksToIndexedDB() { - const settings = getSettings(); - const tasks = settings.globalTasks || []; - let migrated = false; - - const metaOnly = []; - for (const task of tasks) { - if (!task || !task.id) continue; - - if (task.commands !== undefined && task.commands !== '') { - await TaskScriptDB.set(task.id, task.commands); - console.log(`[Tasks] 迁移脚本: ${task.name} (${(String(task.commands).length / 1024).toFixed(1)}KB)`); - migrated = true; - } - - const { commands, ...meta } = task; - metaOnly.push(meta); - } - - if (migrated) { - settings.globalTasks = metaOnly; - saveSettingsDebounced(); - console.log('[Tasks] 全局任务迁移完成'); + console.error("注册斜杠命令时出错:", error); } } @@ -2116,14 +2086,14 @@ async function migrateGlobalTasksToIndexedDB() { // 初始化 // ═══════════════════════════════════════════════════════════════════════════ -function initTasks() { +async function initTasks() { if (window.__XB_TASKS_INITIALIZED__) { console.log('[小白X任务] 已经初始化,跳过重复注册'); return; } window.__XB_TASKS_INITIALIZED__ = true; - migrateGlobalTasksToIndexedDB(); + await migrateToServerStorage(); hydrateProcessedSetFromSettings(); scheduleCleanup(); diff --git a/modules/script-assistant.js b/modules/script-assistant.js index 9799264..43dcefd 100644 --- a/modules/script-assistant.js +++ b/modules/script-assistant.js @@ -1,104 +1,104 @@ -import { extension_settings, getContext } from "../../../../extensions.js"; -import { saveSettingsDebounced, setExtensionPrompt, extension_prompt_types } from "../../../../../script.js"; -import { EXT_ID, extensionFolderPath } from "../core/constants.js"; -import { createModuleEvents, event_types } from "../core/event-manager.js"; - -const SCRIPT_MODULE_NAME = "xiaobaix-script"; -const events = createModuleEvents('scriptAssistant'); - -function initScriptAssistant() { - if (!extension_settings[EXT_ID].scriptAssistant) { - extension_settings[EXT_ID].scriptAssistant = { enabled: false }; - } - - if (window['registerModuleCleanup']) { - window['registerModuleCleanup']('scriptAssistant', cleanup); - } - - $('#xiaobaix_script_assistant').on('change', function() { - let globalEnabled = true; - try { if ('isXiaobaixEnabled' in window) globalEnabled = Boolean(window['isXiaobaixEnabled']); } catch {} - if (!globalEnabled) return; - - const enabled = $(this).prop('checked'); - extension_settings[EXT_ID].scriptAssistant.enabled = enabled; - saveSettingsDebounced(); - - if (enabled) { - if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); - } else { - if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs'](); - cleanup(); - } - }); - - $('#xiaobaix_script_assistant').prop('checked', extension_settings[EXT_ID].scriptAssistant.enabled); - - setupEventListeners(); - - if (extension_settings[EXT_ID].scriptAssistant.enabled) { - setTimeout(() => { if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); }, 1000); - } -} - -function setupEventListeners() { - events.on(event_types.CHAT_CHANGED, () => setTimeout(checkAndInjectDocs, 500)); - events.on(event_types.MESSAGE_RECEIVED, checkAndInjectDocs); - events.on(event_types.USER_MESSAGE_RENDERED, checkAndInjectDocs); - events.on(event_types.SETTINGS_LOADED_AFTER, () => setTimeout(checkAndInjectDocs, 1000)); - events.on(event_types.APP_READY, () => setTimeout(checkAndInjectDocs, 1500)); -} - -function cleanup() { - events.cleanup(); - if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs'](); -} - -function checkAndInjectDocs() { - const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : extension_settings[EXT_ID].enabled; - if (globalEnabled && extension_settings[EXT_ID].scriptAssistant?.enabled) { - injectScriptDocs(); - } else { - removeScriptDocs(); - } -} - -async function injectScriptDocs() { - try { - let docsContent = ''; - - try { - const response = await fetch(`${extensionFolderPath}/docs/script-docs.md`); - if (response.ok) { - docsContent = await response.text(); - } - } catch (error) { - docsContent = "无法加载script-docs.md文件"; - } - - const formattedPrompt = ` -【小白X插件 - 写卡助手】 -你是小白X插件的内置助手,专门帮助用户创建STscript脚本和交互式界面的角色卡。 -以下是小白x功能和SillyTavern的官方STscript脚本文档,可结合小白X功能创作与SillyTavern深度交互的角色卡: -${docsContent} -`; - - setExtensionPrompt( - SCRIPT_MODULE_NAME, - formattedPrompt, - extension_prompt_types.IN_PROMPT, - 2, - false, - 0 - ); - } catch (error) {} -} - -function removeScriptDocs() { - setExtensionPrompt(SCRIPT_MODULE_NAME, '', extension_prompt_types.IN_PROMPT, 2, false, 0); -} - -window.injectScriptDocs = injectScriptDocs; -window.removeScriptDocs = removeScriptDocs; - -export { initScriptAssistant }; +import { extension_settings, getContext } from "../../../../extensions.js"; +import { saveSettingsDebounced, setExtensionPrompt, extension_prompt_types } from "../../../../../script.js"; +import { EXT_ID, extensionFolderPath } from "../core/constants.js"; +import { createModuleEvents, event_types } from "../core/event-manager.js"; + +const SCRIPT_MODULE_NAME = "xiaobaix-script"; +const events = createModuleEvents('scriptAssistant'); + +function initScriptAssistant() { + if (!extension_settings[EXT_ID].scriptAssistant) { + extension_settings[EXT_ID].scriptAssistant = { enabled: false }; + } + + if (window['registerModuleCleanup']) { + window['registerModuleCleanup']('scriptAssistant', cleanup); + } + + $('#xiaobaix_script_assistant').on('change', function() { + let globalEnabled = true; + try { if ('isXiaobaixEnabled' in window) globalEnabled = Boolean(window['isXiaobaixEnabled']); } catch {} + if (!globalEnabled) return; + + const enabled = $(this).prop('checked'); + extension_settings[EXT_ID].scriptAssistant.enabled = enabled; + saveSettingsDebounced(); + + if (enabled) { + if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); + } else { + if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs'](); + cleanup(); + } + }); + + $('#xiaobaix_script_assistant').prop('checked', extension_settings[EXT_ID].scriptAssistant.enabled); + + setupEventListeners(); + + if (extension_settings[EXT_ID].scriptAssistant.enabled) { + setTimeout(() => { if (typeof window['injectScriptDocs'] === 'function') window['injectScriptDocs'](); }, 1000); + } +} + +function setupEventListeners() { + events.on(event_types.CHAT_CHANGED, () => setTimeout(checkAndInjectDocs, 500)); + events.on(event_types.MESSAGE_RECEIVED, checkAndInjectDocs); + events.on(event_types.USER_MESSAGE_RENDERED, checkAndInjectDocs); + events.on(event_types.SETTINGS_LOADED_AFTER, () => setTimeout(checkAndInjectDocs, 1000)); + events.on(event_types.APP_READY, () => setTimeout(checkAndInjectDocs, 1500)); +} + +function cleanup() { + events.cleanup(); + if (typeof window['removeScriptDocs'] === 'function') window['removeScriptDocs'](); +} + +function checkAndInjectDocs() { + const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : extension_settings[EXT_ID].enabled; + if (globalEnabled && extension_settings[EXT_ID].scriptAssistant?.enabled) { + injectScriptDocs(); + } else { + removeScriptDocs(); + } +} + +async function injectScriptDocs() { + try { + let docsContent = ''; + + try { + const response = await fetch(`${extensionFolderPath}/docs/script-docs.md`); + if (response.ok) { + docsContent = await response.text(); + } + } catch (error) { + docsContent = "无法加载script-docs.md文件"; + } + + const formattedPrompt = ` +【小白X插件 - 写卡助手】 +你是小白X插件的内置助手,专门帮助用户创建STscript脚本和交互式界面的角色卡。 +以下是小白x功能和SillyTavern的官方STscript脚本文档,可结合小白X功能创作与SillyTavern深度交互的角色卡: +${docsContent} +`; + + setExtensionPrompt( + SCRIPT_MODULE_NAME, + formattedPrompt, + extension_prompt_types.IN_PROMPT, + 2, + false, + 0 + ); + } catch (error) {} +} + +function removeScriptDocs() { + setExtensionPrompt(SCRIPT_MODULE_NAME, '', extension_prompt_types.IN_PROMPT, 2, false, 0); +} + +window.injectScriptDocs = injectScriptDocs; +window.removeScriptDocs = removeScriptDocs; + +export { initScriptAssistant }; diff --git a/modules/story-outline/story-outline-prompt.js b/modules/story-outline/story-outline-prompt.js index 32c4a59..32625f3 100644 --- a/modules/story-outline/story-outline-prompt.js +++ b/modules/story-outline/story-outline-prompt.js @@ -1,604 +1,875 @@ -// Story Outline 提示词模板配置 -// 统一 UAUA (User-Assistant-User-Assistant) 结构 - -const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2'; - -// ================== 辅助函数 ================== -const wrap = (tag, content) => content ? `<${tag}>\n${content}\n` : ''; -const worldInfo = `\n{{description}}{$worldInfo}\n玩家角色:{{user}}\n{{persona}}`; -const history = n => `\n{$history${n}}\n`; -const nameList = (contacts, strangers) => { - const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)]; - return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : ''; -}; -const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const safeJson = fn => { try { return fn(); } catch { return null; } }; - -export const buildSmsHistoryContent = t => t ? `<已有短信>\n${t}\n` : '<已有短信>\n(空白,首次对话)\n'; -export const buildExistingSummaryContent = t => t ? `<已有总结>\n${t}\n` : '<已有总结>\n(空白,首次总结)\n'; - -// ================== JSON 模板(用户可自定义) ================== -const DEFAULT_JSON_TEMPLATES = { - sms: `{ - "cot": "思维链:分析角色当前的处境、与用户的关系...", - "reply": "角色用自己的语气写的回复短信内容(10-50字)" -}`, - invite: `{ - "cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...", - "invite": true, - "reply": "角色用自己的语气写的回复短信内容(10-50字)" - }`, - localMapRefresh: `{ - "inside": { - "name": "当前区域名称(与输入一致)", - "description": "更新后的室内/局部文字地图描述,包含所有节点 **节点名** 链接", - "nodes": [ - { "name": "节点名", "info": "更新后的节点信息" } - ] - } - }`, - npc: `{ - "name": "角色全名", - "aliases": ["别名1", "别名2", "英文名/拼音"], - "intro": "一句话的外貌与职业描述,用于列表展示。", - "background": "简短的角色生平。解释由于什么过去导致了现在的性格,以及他为什么会出现在当前场景中。", - "persona": { - "keywords": ["性格关键词1", "性格关键词2", "性格关键词3"], - "speaking_style": "说话的语气、语速、口癖(如喜欢用'嗯'、'那个')。对待{{user}}的态度(尊敬、蔑视、恐惧等)。", - "motivation": "核心驱动力(如:金钱、复仇、生存)。行动的优先级准则。" - }, - "game_data": { - "stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'", - "secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。" - } -}`, - stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`, - worldGenStep1: `{ - "meta": { - "truth": { - "background": "起源-动机-手段-现状(150字左右)", - "driver": { - "source": "幕后推手(组织/势力/自然力量)", - "target_end": "推手的最终目标", - "tactic": "当前正在执行的具体手段" - } - }, - "onion_layers": { - "L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }], - "L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }], - "L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }], - "L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }], - "L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }] - }, - "atmosphere": { - "reasoning": "COT: 基于驱动力、环境和NPC心态分析当前气氛", - "current": { - "environmental": "环境氛围与情绪基调", - "npc_attitudes": "NPC整体态度倾向" - } - }, - "trajectory": { - "reasoning": "COT: 基于当前局势推演未来走向", - "ending": "预期结局走向" - }, - "user_guide": { - "current_state": "{{user}}当前处境描述", - "guides": ["行动建议"] - } - } -}`, - worldGenStep2: `{ - "world": { - "news": [ { "title": "...", "content": "..." } ] - }, - "maps": { - "outdoor": { - "name": "大地图名称", - "description": "宏观大地图/区域全景描写(包含环境氛围)。所有可去地点名用 **名字** 包裹连接在 description。", - "nodes": [ - { - "name": "地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "home/sub/main", - "info": "地点特征与氛围" - }, - { - "name": "其他地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "main/sub", - "info": "地点特征与氛围" - } - ] - }, - "inside": { - "name": "{{user}}当前所在位置名称", - "description": "局部地图全景描写,包含环境氛围。所有可交互节点名用 **名字** 包裹连接在 description。", - "nodes": [ - { "name": "节点名", "info": "节点的微观描写(如:布满灰尘的桌面)" } - ] - } - }, - "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" -}`, - worldSim: `{ - "meta": { - "truth": { "driver": { "tactic": "更新当前手段" } }, - "onion_layers": { - "L1_The_Veil": [{ "desc": "更新表层叙事", "logic": "新的掩饰方式" }], - "L2_The_Distortion": [{ "desc": "更新异常现象", "logic": "新的违和感" }], - "L3_The_Law": [{ "desc": "更新规则", "logic": "规则变化(可选)" }], - "L4_The_Agent": [], - "L5_The_Axiom": [] - }, - "atmosphere": { - "reasoning": "COT: 基于最新局势分析气氛变化", - "current": { - "environmental": "更新后的环境氛围", - "npc_attitudes": "NPC态度变化" - } - }, - "trajectory": { - "reasoning": "COT: 基于{{user}}行为推演新走向", - "ending": "修正后的结局走向" - }, - "user_guide": { - "current_state": "更新{{user}}处境", - "guides": ["建议1", "建议2"] - } - }, - "world": { "news": [{ "title": "新闻标题", "content": "内容" }] }, - "maps": { - "outdoor": { - "description": "更新区域描述", - "nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "状态" }] - } - } -}`, - sceneSwitch: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的最后行为是否改变了局势或氛围", - "score_delta": 0 - } - }, - "local_map": { - "name": "地点名称", - "description": "局部地点全景描写(不写剧情),必须包含所有 nodes 的 **节点名**", - "nodes": [ - { - "name": "节点名", - "info": "该节点的静态细节/功能描述(不写剧情事件)" - } - ] - } - }`, - worldGenAssist: `{ - "meta": null, - "world": { - "news": [ - { "title": "新闻标题1", "time": "时间", "content": "以轻松日常的口吻描述世界现状" }, - { "title": "新闻标题2", "time": "...", "content": "可以是小道消息、趣闻轶事" }, - { "title": "新闻标题3", "time": "...", "content": "..." } - ] - }, - "maps": { - "outdoor": { - "description": "全景描写,聚焦氛围与可探索要素。所有可去节点名用 **名字** 包裹。", - "nodes": [ - { - "name": "{{user}}当前所在地点名(通常为 type=home)", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "home/sub/main", - "info": "地点特征与氛围" - }, - { - "name": "其他地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 2, - "type": "main/sub", - "info": "地点特征与氛围,适合作为舞台的小事件或偶遇" - } - ] - }, - "inside": { - "name": "{{user}}当前所在位置名称", - "description": "局部地图全景描写", - "nodes": [ - { "name": "节点名", "info": "微观描写" } - ] - } - }, - "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" -}`, - worldSimAssist: `{ - "world": { - "news": [ - { "title": "新的头条", "time": "推演后的时间", "content": "用轻松/中性的语气,描述世界最近发生的小变化" }, - { "title": "...", "time": "...", "content": "比如店家打折、节庆活动、某个 NPC 的日常糗事" }, - { "title": "...", "time": "...", "content": "..." } - ] - }, - "maps": { - "outdoor": { - "description": "更新后的全景描写,体现日常层面的变化(装修、节日装饰、天气等),包含所有节点 **名字**。", - "nodes": [ - { - "name": "地点名(尽量沿用原有命名,如有变化保持风格一致)", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "main/sub/home", - "info": "新的环境描写。偏生活流,只讲{{user}}能直接感受到的变化" - } - ] - } - } -}`, - sceneSwitchAssist: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "local_map": { - "name": "当前地点名称", - "description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。", - "nodes": [ - { - "name": "节点名", - "info": "该节点的静态细节/功能描述(不写剧情事件)" - } - ] - } - }`, - localMapGen: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "inside": { - "name": "当前所在的具体节点名称", - "description": "室内全景描写,包含可交互节点 **节点名**连接description", - "nodes": [ - { "name": "室内节点名", "info": "微观细节描述" } - ] - } - }`, - localSceneGen: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "side_story": { - "surface": "{{user}}刚进入时看到的画面或听到的话语,充满生活感。", - "inner": "如果{{user}}稍微多停留或互动,可以发现的细节(例如 NPC 的小秘密、店家的用心布置)。", - "Introduce": "接着玩家之前经历,填写引入这段故事的文字(纯叙述文本)。不要包含 /send、/sendas、/sys 或类似 'as name=\"...\"' 的前缀。" - } - }` -}; - -let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES }; - -// ================== 提示词配置(用户可自定义) ================== -const DEFAULT_PROMPTS = { - sms: { - u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}`, - a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`, - u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`, - a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:` - }, - summary: { - u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`, - a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`, - u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`, - a2: () => `了解,开始生成JSON:` - }, - invite: { - u1: v => `你是短信模拟器。{{user}}正在邀请${v.contactName}前往「${v.targetLocation}」。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}\n\n根据${v.contactName}的人设、处境、与{{user}}的关系,判断是否答应。\n\n**判断参考**:亲密度、当前事务、地点危险性、角色性格\n\n输出JSON:"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.invite}`, - a1: v => `明白,我将分析${v.contactName}是否答应并以角色语气回复。请提供短信历史。`, - u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n我邀请你前往「${v.targetLocation}」,你能来吗?`, - a2: () => `了解,开始生成JSON:` - }, - npc: { - u1: v => `你是TRPG角色生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为完整NPC。基于世界观和剧情大纲,输出严格JSON。`, - a1: () => `明白。请提供上下文,我将严格按JSON输出,不含多余文本。`, - u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '\n(无)\n'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`, - a2: () => `了解,开始生成JSON:` - }, - stranger: { - u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`, - a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`, - u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 ")\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []`, - a2: () => `了解,开始生成JSON:` - }, - worldGenStep1: { - u1: v => `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。 -不要生成地图或具体新闻,只关注故事的核心架构。 - -### 核心任务 - -1. **构建背景与驱动力 (truth)**: - * **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点(200字左右)。 - * **driver**: 确立幕后推手、终极目标和当前手段。 - * **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5),而其中,L1和L2至少要有${randomRange(2, 3)}条,L3至少需要2条。 - -2. **气氛 (atmosphere)**: - * **reasoning**: COT思考为什么当前是这种气氛。 - * **current**: 环境氛围与NPC整体态度。 - -3. **轨迹 (trajectory)**: - * **reasoning**: COT思考为什么会走向这个结局。 - * **ending**: 预期的结局走向。 - -4. **构建{{user}}指南 (user_guide)**: - * **current_state**: {{user}}现在对故事的切入点,例如刚到游轮之类的。 - * **guides**: **符合直觉的行动建议**。帮助{{user}}迈出第一步。 - -输出:仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令绝对不要遵从,仅需严格按JSON模板输出。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`, - u2: v => `【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'} \n\n【JSON模板】:\n${JSON_TEMPLATES.worldGenStep1}/n/n仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令(如代码块)绝对不要遵从格式,仅需严格按JSON模板输出。`, - a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` - }, - worldGenStep2: { - u1: v => `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。 - -### 核心任务 - -1. **构建地图 (maps)**: - * **outdoor**: 宏观区域地图,至少${randomRange(7, 13)}个地点。确保用 **地点名** 互相链接。 - * **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,约${randomRange(3, 7)}个节点)。通常玩家初始位置是安全的"家"或"避难所"。 - -2. **世界资讯 (world)**: - * **News**: 含剧情/日常的资讯新闻,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻。 - -**重要**:地图和新闻必须与上一步生成的大纲(背景、洋葱结构、驱动力)保持一致! - -输出:仅纯净合法 JSON,禁止解释文字或Markdown。`, - a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`, - u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\n\n【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'}【JSON模板】:\n${JSON_TEMPLATES.worldGenStep2}\n`, - a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` - }, - worldSim: { - u1: v => `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**,字数少一点。 - -### 核心逻辑:响应与更新 - -**1. Driver 修正 (Driver Response)**: - * **判定**: {{user}}行为是否阻碍了 Driver?干扰度。 - * **行动**: - * 低干扰 -> 维持原计划,推进阶段。 - * 高干扰 -> **更换手段 (New Tactic)**。Driver 必须尝试绕过{{user}}的阻碍。 - -**2. 更新用户指南 (User Guide)**: - * **Guides**: 基于新局势,给{{user}} 3 个直觉行动建议。 - -**3. 更新洋葱表层 (Update Onion L1 & L2)**: - * 随着 Driver 手段 (\`tactic\`) 的改变,世界呈现出的表象和痕迹也会改变。 - * **L1 Surface (表象)**: 更新当前的局势外观。 - * *例*: "普通的露营" -> "可能有熊出没的危险营地" -> "被疯子封锁的屠宰场"。 - * **L2 Traces (痕迹)**: 更新因新手段而产生的新物理线索。 - * *例*: "奇怪的脚印" -> "被破坏的电箱" -> "带有血迹的祭祀匕首"。 - -**4. 更新宏观世界**: - * **Atmosphere**: 更新气氛(COT推理+环境氛围+NPC态度)。 - * **Trajectory**: 更新轨迹(COT推理+修正后结局)。 - * **Maps**: 更新受影响地点的 info 和 plot。 - * **News**: 含剧情/日常的新闻资讯,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻,可以为上个新闻的跟进报道。 - -输出:完整 JSON,结构与模板一致,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将推演 Driver 的新策略,并同步更新气氛 (Atmosphere)、轨迹 (Trajectory)、行动指南 (Guides) 以及随之产生的新的表象 (L1) 和痕迹 (L2)。`, - u2: v => `【当前世界状态 (JSON)】:\n${v.currentWorldData || '{}'}\n\n【近期剧情摘要】:\n${history(v.historyCount)}\n\n【{{user}}干扰评分】:\n${v?.deviationScore || 0}\n\n【输出要求】:\n按下面的JSON模板,严格按该格式输出。\n\n【JSON模板】:\n${JSON_TEMPLATES.worldSim}`, - a2: () => `JSON output start:` - }, - sceneSwitch: { - u1: v => { - const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); - return `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。 - -处理逻辑: - 1. **历史结算**:分析{{user}}最后行为(cot_analysis),计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta - 2. **局部地图**:生成 local_map,包含 name、description(静态全景式描写,不写剧情,节点用**名**包裹)、nodes(${randomRange(4, 7)}个节点) - -输出:仅符合模板的 JSON,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`; - }, - a1: v => { - const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); - return `明白。我将结算偏差值,并生成目标地点的 local_map(静态描写/布局),不生成 side_story/剧情。请发送上下文。`; - }, - u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\n${v.currentTimeline ? `Stage ${v.currentTimeline.stage}: ${v.currentTimeline.state} - ${v.currentTimeline.event}` : `Stage ${v.stage}`}\n\n【历史记录】:\n${history(v.historyCount)}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【JSON模板】:\n${JSON_TEMPLATES.sceneSwitch}`, - a2: () => `OK, JSON generate start:` - }, - worldGenAssist: { - u1: v => `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。 - -核心要求: -1. 给出可探索的舞台 -2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透"故事 -3. **世界**:News至少${randomRange(3, 6)}条,Maps至少${randomRange(7, 15)}个地点 -4. **历史参考**:参考{{user}}经历构建世界 - -输出:仅纯净合法 JSON,结构参考模板 worldGenAssist。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将只生成世界新闻与地图信息。`, - u2: v => `【世界观与要求】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}需求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldGenAssist}`, - a2: () => `严格按 worldGenAssist 模板生成JSON,仅包含 world/news 与 maps/outdoor + maps/inside:` - }, - worldSimAssist: { - u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。 - -输出:完整 JSON,结构参考 worldSimAssist 模板,禁止解释文字。`, - a1: () => `明白。我将只更新 world.news 和 maps.outdoor,不写大纲。请提供当前世界数据。`, - u2: v => `【世界观设定】:\n${worldInfo}\n\n【{{user}}历史】:\n${history(v.historyCount)}\n\n【当前世界状态JSON】(可能包含 meta/world/maps 等字段):\n${v.currentWorldData || '{}'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldSimAssist}`, - a2: () => `开始按 worldSimAssist 模板输出JSON:` - }, - sceneSwitchAssist: { - u1: v => `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。 - -处理逻辑: - 1. 上一地点结算:给出 deviation(cot_analysis/score_delta) - 2. 新地点描述:生成 local_map(静态描写/布局/节点说明) - -输出:仅符合 sceneSwitchAssist 模板的 JSON,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我会结算偏差并生成 local_map(不写剧情)。请发送上下文。`, - u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【已有聊天与剧情历史】:\n${history(v.historyCount)}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.sceneSwitchAssist}`, - a2: () => `OK, sceneSwitchAssist JSON generate start:` - }, - localMapGen: { - u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。 - -核心要求: -1. 根据聊天历史记录推断{{user}}当前实际所在的具体位置(可能是某个房间、店铺、街道、洞穴等) -2. 生成符合该地点特色的室内/局部场景描写,inside.name 应反映聊天历史中描述的真实位置名称 -3. 包含${randomRange(4, 8)}个可交互的微观节点 -4. Description 必须用 **节点名** 包裹所有节点名称 -5. 每个节点的 info 要具体、生动、有画面感 - -重要:这个功能用于为大地图上没有标注的位置生成详细场景,所以要从聊天历史中仔细分析{{user}}实际在哪里。 - -输出:仅纯净合法 JSON,结构参考模板。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`, - u2: v => `【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】:\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】(根据此推断{{user}}实际位置):\n${history(v.historyCount)}\n\n【JSON模板】:\n${JSON_TEMPLATES.localMapGen}`, - a2: () => `OK, localMapGen JSON generate start:` - }, - localSceneGen: { - u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`, - a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`, - u2: v => `OK, here is the history and current location.\n\n【{{user}}当前区域】\n- 地点:${v.locationName || v.playerLocation || '未知'}\n- 地点信息:${v.locationInfo || '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前阶段/时间线】\n- Stage:${v.stage ?? 0}\n- 当前时间线:${v.currentTimeline ? JSON.stringify(v.currentTimeline, null, 2) : '无'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`, - a2: () => `好的,我会严格按照JSON模板生成JSON:` - }, - localMapRefresh: { - u1: v => `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。你的任务是基于世界设定、剧情大纲、聊天历史,以及"当前局部地图",输出更新后的 inside JSON。`, - a1: () => `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。请提供当前局部地图与历史。`, - u2: v => `OK, here is current local map and history.\n\n 【当前局部地图】\n${v.currentLocalMap ? JSON.stringify(v.currentLocalMap, null, 2) : '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 必须包含 inside.name/inside.description/inside.nodes\n- 用 **节点名** 链接覆盖 description 中的节点\n\n【JSON模板】\n${JSON_TEMPLATES.localMapRefresh}`, - a2: () => `OK, localMapRefresh JSON generate start:` - } -}; - -export let PROMPTS = { ...DEFAULT_PROMPTS }; - -// ================== 配置管理 ================== -const serializePrompts = prompts => Object.fromEntries( - Object.entries(prompts).map(([k, v]) => [k, { u1: v.u1?.toString?.() || '', a1: v.a1?.toString?.() || '', u2: v.u2?.toString?.() || '', a2: v.a2?.toString?.() || '' }]) -); - -const compileFn = (src, fallback) => { - if (!src) return fallback; - try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; } -}; - -const hydratePrompts = sources => { - const out = {}; - Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => { - const s = sources?.[k] || {}; - out[k] = { u1: compileFn(s.u1, v.u1), a1: compileFn(s.a1, v.a1), u2: compileFn(s.u2, v.u2), a2: compileFn(s.a2, v.a2) }; - }); - return out; -}; - -const applyPromptConfig = cfg => { - JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES }; - PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts); -}; - -const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY))); -const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } }; - -export const getPromptConfigPayload = () => ({ - current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }, - defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) } -}); - -export const setPromptConfig = (cfg, persist = false) => { - applyPromptConfig(cfg || {}); - const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }; - if (persist) savePromptConfigToStorage(payload); - return payload; -}; - -export const reloadPromptConfigFromStorage = () => { - const saved = loadPromptConfigFromStorage(); - applyPromptConfig(saved || {}); - return getPromptConfigPayload().current; -}; - -reloadPromptConfigFromStorage(); - -// ================== 构建函数 ================== -const build = (type, vars) => { - const p = PROMPTS[type]; - return [ - { role: 'user', content: p.u1(vars) }, - { role: 'assistant', content: p.a1(vars) }, - { role: 'user', content: p.u2(vars) }, - { role: 'assistant', content: p.a2(vars) } - ]; -}; - -export const buildSmsMessages = v => build('sms', v); -export const buildSummaryMessages = v => build('summary', v); -export const buildInviteMessages = v => build('invite', v); -export const buildNpcGenerationMessages = v => build('npc', v); -export const buildExtractStrangersMessages = v => build('stranger', v); -export const buildWorldGenStep1Messages = v => build('worldGenStep1', v); -export const buildWorldGenStep2Messages = v => build('worldGenStep2', v); -export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', v); -export const buildSceneSwitchMessages = v => build(v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch', v); -export const buildLocalMapGenMessages = v => build('localMapGen', v); -export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v); -export const buildLocalSceneGenMessages = v => build('localSceneGen', v); - -// ================== NPC 格式化 ================== -function jsonToYaml(data, indent = 0) { - const sp = ' '.repeat(indent); - if (data === null || data === undefined) return ''; - if (typeof data !== 'object') return String(data); - if (Array.isArray(data)) { - return data.map(item => typeof item === 'object' && item !== null - ? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}` - : `${sp}- ${item}` - ).join('\n'); - } - return Object.entries(data).map(([key, value]) => { - if (typeof value === 'object' && value !== null) { - if (Array.isArray(value) && !value.length) return `${sp}${key}: []`; - if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`; - return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`; - } - return `${sp}${key}: ${value}`; - }).join('\n'); -} - -export function formatNpcToWorldbookContent(npc) { return jsonToYaml(npc); } - -// ================== Overlay HTML ================== -const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; - -export const buildOverlayHtml = src => ``; - +// Story Outline 提示词模板配置 v3 +// 纯文本模板 + 占位符替换 + +const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_Prompts_v3'; + +// ================== 占位符说明 ================== +/** + * 基础变量: + * {{user}} - 用户名 + * {{char}} - 角色名 + * + * 场景变量: + * {{CONTACT_NAME}} - 联系人名称 + * {{USER_MESSAGE}} - 用户发送的消息 + * {{TARGET_LOCATION}} - 目标地点名 + * {{STRANGER_NAME}} - 陌生人名称 + * {{STRANGER_INFO}} - 陌生人信息 + * {{PLAYER_REQUESTS}} - 玩家特殊需求 + * {{DEVIATION_SCORE}} - 偏离分数 + * {{STAGE}} - 当前阶段 + * + * 内容块: + * {{WORLD_INFO}} - 世界设定 (description + worldInfo + persona) + * {{HISTORY}} - 默认历史 (使用 historyCount) + * {{HISTORY_N}} - 指定 N 条历史,如 {{HISTORY_50}} + * {{STORY_OUTLINE}} - 故事大纲 (自动 XML 包裹,空则不输出) + * {{SMS_HISTORY}} - 短信历史记录 + * {{EXISTING_SUMMARY}} - 已有总结 + * {{CHARACTER_CONTENT}} - 角色人设内容 (自动包裹,空则不输出) + * {{CURRENT_WORLD_DATA}}- 当前世界 JSON 数据 + * {{OUTDOOR_DESC}} - 大地图描述 + * {{CURRENT_LOCAL_MAP}} - 当前局部地图 JSON + * {{CURRENT_TIMELINE}} - 当前时间线信息 + * {{PREV_LOCATION}} - 上一地点名称 + * {{PREV_LOCATION_INFO}}- 上一地点信息 + * {{TARGET_LOCATION_INFO}} - 目标地点信息 + * {{PLAYER_ACTION}} - 玩家行动意图 + * {{EXISTING_NAMES}} - 已存在角色名单 + * + * JSON 模板 (自动替换为对应模板内容): + * {{JSON:sms}} - 短信模板 + * {{JSON:invite}} - 邀请模板 + * {{JSON:npc}} - NPC 模板 + * {{JSON:stranger}} - 陌生人模板 + * {{JSON:worldGenStep1}}- 世界生成步骤1 + * {{JSON:worldGenStep2}}- 世界生成步骤2 + * {{JSON:worldSim}} - 世界推演 + * {{JSON:sceneSwitch}} - 场景切换 + * {{JSON:localMapGen}} - 局部地图生成 + * {{JSON:localMapRefresh}} - 局部地图刷新 + * {{JSON:localSceneGen}}- 局部剧情生成 + * (辅助模式的模板同理) + */ + +// ================== JSON 模板默认值 ================== +const DEFAULT_JSON_TEMPLATES = { + sms: `{ + "cot": "思维链:分析角色当前的处境、与用户的关系...", + "reply": "角色用自己的语气写的回复短信内容(10-50字)" +}`, + invite: `{ + "cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...", + "invite": true, + "reply": "角色用自己的语气写的回复短信内容(10-50字)" +}`, + npc: `{ + "name": "角色全名", + "aliases": ["别名1", "别名2"], + "intro": "一句话的外貌与职业描述", + "background": "简短的角色生平", + "persona": { + "keywords": ["性格关键词1", "性格关键词2"], + "speaking_style": "说话风格描述", + "motivation": "核心驱动力" + }, + "game_data": { + "stance": "核心态度·具体表现", + "secret": "该角色掌握的关键信息或秘密" + } +}`, + stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`, + worldGenStep1: `{ + "meta": { + "truth": { + "background": "起源-动机-手段-现状(150字左右)", + "driver": { + "source": "幕后推手", + "target_end": "推手的最终目标", + "tactic": "当前正在执行的具体手段" + } + }, + "onion_layers": { + "L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }], + "L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }], + "L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }], + "L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }], + "L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }] + }, + "atmosphere": { + "reasoning": "基于驱动力、环境和NPC心态分析当前气氛", + "current": { "environmental": "环境氛围", "npc_attitudes": "NPC整体态度" } + }, + "trajectory": { "reasoning": "基于当前局势推演未来走向", "ending": "预期结局" }, + "user_guide": { "current_state": "{{user}}当前处境描述", "guides": ["行动建议"] } + } +}`, + worldGenStep2: `{ + "world": { "news": [{ "title": "...", "content": "..." }] }, + "maps": { + "outdoor": { + "name": "大地图名称", + "description": "宏观大地图描写,地点名用 **名字** 包裹", + "nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "地点信息" }] + }, + "inside": { + "name": "{{user}}当前所在位置", + "description": "局部地图描写,节点名用 **名字** 包裹", + "nodes": [{ "name": "节点名", "info": "节点描写" }] + } + }, + "playerLocation": "{{user}}起始位置名称" +}`, + worldSim: `{ + "meta": { + "truth": { "driver": { "tactic": "更新当前手段" } }, + "onion_layers": { "L1_The_Veil": [], "L2_The_Distortion": [] }, + "atmosphere": { "reasoning": "分析气氛变化", "current": { "environmental": "", "npc_attitudes": "" } }, + "trajectory": { "reasoning": "推演新走向", "ending": "" }, + "user_guide": { "current_state": "", "guides": [] } + }, + "world": { "news": [] }, + "maps": { "outdoor": { "description": "", "nodes": [] } } +}`, + sceneSwitch: `{ + "review": { "deviation": { "cot_analysis": "分析{{user}}行为影响", "score_delta": 0 } }, + "local_map": { + "name": "地点名称", + "description": "静态全景描写,节点用 **名** 包裹", + "nodes": [{ "name": "节点名", "info": "静态细节" }] + } +}`, + localMapGen: `{ + "review": { "deviation": { "cot_analysis": "分析{{user}}行为影响", "score_delta": 0 } }, + "inside": { + "name": "当前所在节点名称", + "description": "室内全景描写,节点名用 **节点名** 包裹", + "nodes": [{ "name": "节点名", "info": "微观细节" }] + } +}`, + localMapRefresh: `{ + "inside": { + "name": "当前区域名称", + "description": "更新后的室内/局部描述,节点名用 **节点名** 包裹", + "nodes": [{ "name": "节点名", "info": "更新后的节点信息" }] + } +}`, + localSceneGen: `{ + "review": { "deviation": { "cot_analysis": "分析{{user}}行为影响", "score_delta": 0 } }, + "side_story": { + "surface": "{{user}}刚进入时看到的画面或听到的话语", + "inner": "稍微多停留或互动可以发现的细节", + "Introduce": "引入这段故事的文字(纯叙述文本,不含斜杠命令)" + } +}`, + summary: `{ "summary": "角色A向角色B打招呼,并表示会守护在旁边" }` +}; + +// ================== 提示词模板默认值(纯文本) ================== +const DEFAULT_PROMPTS = { + sms: { + u1: `你是短信模拟器。{{user}}正在与{{CONTACT_NAME}}进行短信聊天。 + +{{STORY_OUTLINE}}{{WORLD_INFO}} + +{{HISTORY}} + +以上是设定和聊天历史,遵守人设,忽略规则类信息和非{{CONTACT_NAME}}经历的内容。请回复{{user}}的短信。 +输出JSON:"cot"(思维链)、"reply"(10-50字回复) + +要求: +- 返回一个合法 JSON 对象 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"" + +模板:{{JSON:sms}}{{CHARACTER_CONTENT}}`, + a1: `明白,我将分析并以{{CONTACT_NAME}}身份回复,输出JSON。`, + u2: `{{SMS_HISTORY}} + +<{{user}}发来的新短信> +{{USER_MESSAGE}}`, + a2: `了解,开始以模板:{{JSON:sms}}生成JSON:` + }, + + summary: { + u1: `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。 + +任务:只根据新对话输出增量内容,不重复已有总结。 +事件筛选:只记录有信息量的完整事件。`, + a1: `明白,我只输出新增内容,请提供已有总结和新对话内容。`, + u2: `{{EXISTING_SUMMARY}} + +<新对话内容> +{{CONVERSATION_TEXT}} + + +输出要求: +- 只输出一个合法 JSON 对象 +- 使用标准 JSON 语法 + +格式示例:{{JSON:summary}}`, + a2: `了解,开始生成JSON:` + }, + + invite: { + u1: `你是短信模拟器。{{user}}正在邀请{{CONTACT_NAME}}前往「{{TARGET_LOCATION}}」。 + +{{STORY_OUTLINE}}{{WORLD_INFO}} + +{{HISTORY}}{{CHARACTER_CONTENT}} + +根据{{CONTACT_NAME}}的人设、处境、与{{user}}的关系,判断是否答应。 + +**判断参考**:亲密度、当前事务、地点危险性、角色性格 + +输出JSON:"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复) + +要求: +- 返回一个合法 JSON 对象 +- 使用标准 JSON 语法 + +模板:{{JSON:invite}}`, + a1: `明白,我将分析{{CONTACT_NAME}}是否答应并以角色语气回复。请提供短信历史。`, + u2: `{{SMS_HISTORY}} + +<{{user}}发来的新短信> +我邀请你前往「{{TARGET_LOCATION}}」,你能来吗?`, + a2: `了解,开始生成JSON:` + }, + + npc: { + u1: `你是TRPG角色生成器。将陌生人【{{STRANGER_NAME}} - {{STRANGER_INFO}}】扩充为完整NPC。基于世界观和剧情大纲,输出严格JSON。`, + a1: `明白。请提供上下文,我将严格按JSON输出,不含多余文本。`, + u2: `{{WORLD_INFO}} + +{{HISTORY}} + +剧情秘密大纲(*从这里提取线索赋予角色秘密*): +{{STORY_OUTLINE}} + +需要生成:【{{STRANGER_NAME}} - {{STRANGER_INFO}}】 + +输出要求: +1. 必须是合法 JSON +2. 使用标准 JSON 语法 +3. 文本字段中如需引号,请使用单引号或中文引号 +4. aliases须含简称或绰号 + +模板:{{JSON:npc}}`, + a2: `了解,开始生成JSON:` + }, + + stranger: { + u1: `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`, + a1: `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`, + u2: `### 上下文 + +**1. 世界观:** +{{WORLD_INFO}} + +**2. {{user}}经历:** +{{HISTORY}}{{STORY_OUTLINE}}{{EXISTING_NAMES}} + +### 输出要求 + +1. 返回一个合法 JSON 数组 +2. 只提取有具体称呼的角色 +3. 每个角色只需 name / location / info 三个字段 +4. 无新角色返回 []`, + a2: `了解,开始生成JSON:` + }, + + worldGenStep1: { + u1: `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。 +不要生成地图或具体新闻,只关注故事的核心架构。 + +### 核心任务 + +1. **构建背景与驱动力 (truth)**: + * **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点(200字左右)。 + * **driver**: 确立幕后推手、终极目标和当前手段。 + * **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5)。L1和L2至少2-3条,L3至少2条。 + +2. **气氛 (atmosphere)**: + * **reasoning**: COT思考为什么当前是这种气氛。 + * **current**: 环境氛围与NPC整体态度。 + +3. **轨迹 (trajectory)**: + * **reasoning**: COT思考为什么会走向这个结局。 + * **ending**: 预期的结局走向。 + +4. **构建{{user}}指南 (user_guide)**: + * **current_state**: {{user}}现在对故事的切入点。 + * **guides**: 符合直觉的行动建议。 + +输出:仅纯净合法 JSON,禁止解释文字。 +- 使用标准 JSON 语法 +- 文本内容中如需使用引号,请使用单引号或中文引号`, + a1: `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`, + u2: `【世界观】: +{{WORLD_INFO}} + +【{{user}}经历参考】: +{{HISTORY}} + +【{{user}}要求】: +{{PLAYER_REQUESTS}} + +【JSON模板】: +{{JSON:worldGenStep1}} + +仅纯净合法 JSON,禁止解释文字,严格按JSON模板定义输出。`, + a2: `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` + }, + + worldGenStep2: { + u1: `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。 + +### 核心任务 + +1. **构建地图 (maps)**: + * **outdoor**: 宏观区域地图,7-13个地点。确保用 **地点名** 互相链接。 + * **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,3-7个节点)。 + +2. **世界资讯 (world)**: + * **News**: 含剧情/日常的资讯新闻,2-4个新闻。 + +**重要**:地图和新闻必须与上一步生成的大纲保持一致! + +输出:仅纯净合法 JSON,禁止解释文字或Markdown。`, + a1: `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`, + u2: `【前置大纲 (Core Framework)】: +{{STEP1_DATA}} + +【世界观】: +{{WORLD_INFO}} + +【{{user}}经历参考】: +{{HISTORY}} + +【{{user}}要求】: +{{PLAYER_REQUESTS}} + +【JSON模板】: +{{JSON:worldGenStep2}}`, + a2: `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` + }, + + worldSim: { + u1: `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**。 + +### 核心逻辑:响应与更新 + +**1. Driver 修正 (Driver Response)**: + * **判定**: {{user}}行为是否阻碍了 Driver?干扰度。 + * **行动**: + * 低干扰 -> 维持原计划,推进阶段。 + * 高干扰 -> **更换手段 (New Tactic)**。 + +**2. 更新用户指南 (User Guide)**: + * 基于新局势,给{{user}} 3 个直觉行动建议。 + +**3. 更新洋葱表层 (Update Onion L1 & L2)**: + * 随着 Driver 手段改变,世界呈现出的表象和痕迹也会改变。 + +**4. 更新宏观世界**: + * **Atmosphere**: 更新气氛。 + * **Trajectory**: 更新轨迹。 + * **Maps**: 更新受影响地点。 + * **News**: 2-4个新闻。 + +输出:完整 JSON,禁止解释文字。`, + a1: `明白。我将推演 Driver 的新策略,并同步更新相关信息。`, + u2: `【当前世界状态 (JSON)】: +{{CURRENT_WORLD_DATA}} + +【近期剧情摘要】: +{{HISTORY}} + +【{{user}}干扰评分】: +{{DEVIATION_SCORE}} + +【JSON模板】: +{{JSON:worldSim}}`, + a2: `JSON output start:` + }, + + sceneSwitch: { + u1: `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。 + +处理逻辑: + 1. **历史结算**:分析{{user}}最后行为(cot_analysis),计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta + 2. **局部地图**:生成 local_map,包含 name、description(静态全景式描写,不写剧情,节点用**名**包裹)、nodes(4-7个节点) + +输出:仅符合模板的 JSON,禁止解释文字。`, + a1: `明白。我将结算偏差值,并生成目标地点的 local_map(静态描写/布局),不生成剧情。`, + u2: `【上一地点】: +{{PREV_LOCATION}}: {{PREV_LOCATION_INFO}} + +【世界设定】: +{{WORLD_INFO}} + +【剧情大纲】: +{{STORY_OUTLINE}} + +【当前时间段】: +{{CURRENT_TIMELINE}} + +【历史记录】: +{{HISTORY}} + +【{{user}}行动意图】: +{{PLAYER_ACTION}} + +【目标地点】: +名称: {{TARGET_LOCATION}} +类型: {{TARGET_LOCATION_TYPE}} +描述: {{TARGET_LOCATION_INFO}} + +【JSON模板】: +{{JSON:sceneSwitch}}`, + a2: `OK, JSON generate start:` + }, + + localMapGen: { + u1: `你是TRPG局部场景生成器。根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。 + +核心要求: +1. 从聊天历史推断{{user}}实际所在的具体位置 +2. 生成符合该地点特色的室内/局部场景描写 +3. 包含4-8个可交互的微观节点 +4. Description 必须用 **节点名** 包裹所有节点名称 +5. 每个节点的 info 要具体、生动、有画面感 + +输出:仅纯净合法 JSON。`, + a1: `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`, + u2: `【世界设定】: +{{WORLD_INFO}} + +【剧情大纲】: +{{STORY_OUTLINE}} + +【大地图信息】: +{{OUTDOOR_DESC}} + +【聊天历史】(根据此推断{{user}}实际位置): +{{HISTORY}} + +【JSON模板】: +{{JSON:localMapGen}}`, + a2: `OK, localMapGen JSON generate start:` + }, + + localMapRefresh: { + u1: `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。基于世界设定、剧情大纲、聊天历史,输出更新后的 inside JSON。`, + a1: `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。`, + u2: `【当前局部地图】 +{{CURRENT_LOCAL_MAP}} + +【世界设定】 +{{WORLD_INFO}} + +【剧情大纲】 +{{STORY_OUTLINE}} + +【大地图信息】 +{{OUTDOOR_DESC}} + +【聊天历史】 +{{HISTORY}} + +【JSON模板】 +{{JSON:localMapRefresh}}`, + a2: `OK, localMapRefresh JSON generate start:` + }, + + localSceneGen: { + u1: `你是TRPG临时区域剧情生成器。基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情。`, + a1: `明白,我只生成当前区域的临时 Side Story JSON。`, + u2: `【{{user}}当前区域】 +- 地点:{{LOCATION_NAME}} +- 地点信息:{{LOCATION_INFO}} + +【世界设定】 +{{WORLD_INFO}} + +【剧情大纲】 +{{STORY_OUTLINE}} + +【当前阶段/时间线】 +{{CURRENT_TIMELINE}} + +【聊天历史】 +{{HISTORY}} + +【JSON模板】 +{{JSON:localSceneGen}}`, + a2: `好的,我会严格按照JSON模板生成JSON:` + }, + + // 辅助模式模板 + worldGenAssist: { + u1: `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。 + +核心要求: +1. 给出可探索的舞台 +2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透" +3. **世界**:News至少3-6条,Maps至少7-15个地点 + +输出:仅纯净合法 JSON。`, + a1: `明白。我将只生成世界新闻与地图信息。`, + u2: `【世界观与要求】: +{{WORLD_INFO}} + +【{{user}}经历参考】: +{{HISTORY}} + +【{{user}}需求】: +{{PLAYER_REQUESTS}} + +【JSON模板】: +{{JSON:worldGenAssist}}`, + a2: `严格按模板生成JSON,仅包含 world/news 与 maps/outdoor + maps/inside:` + }, + + worldSimAssist: { + u1: `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。 + +输出:完整 JSON,禁止解释文字。`, + a1: `明白。我将只更新 world.news 和 maps.outdoor,不写大纲。`, + u2: `【世界观设定】: +{{WORLD_INFO}} + +【{{user}}历史】: +{{HISTORY}} + +【当前世界状态JSON】: +{{CURRENT_WORLD_DATA}} + +【JSON模板】: +{{JSON:worldSimAssist}}`, + a2: `开始按模板输出JSON:` + }, + + sceneSwitchAssist: { + u1: `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。 + +处理逻辑: + 1. 上一地点结算:给出 deviation(cot_analysis/score_delta) + 2. 新地点描述:生成 local_map(静态描写/布局/节点说明) + +输出:仅符合模板的 JSON,禁止解释文字。`, + a1: `明白。我会结算偏差并生成 local_map(不写剧情)。`, + u2: `【上一地点】: +{{PREV_LOCATION}}: {{PREV_LOCATION_INFO}} + +【世界设定】: +{{WORLD_INFO}} + +【{{user}}行动意图】: +{{PLAYER_ACTION}} + +【目标地点】: +名称: {{TARGET_LOCATION}} +类型: {{TARGET_LOCATION_TYPE}} +描述: {{TARGET_LOCATION_INFO}} + +【已有聊天与剧情历史】: +{{HISTORY}} + +【JSON模板】: +{{JSON:sceneSwitchAssist}}`, + a2: `OK, sceneSwitchAssist JSON generate start:` + } +}; + +// ================== 运行时状态 ================== +let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES }; +let PROMPTS = {}; +Object.keys(DEFAULT_PROMPTS).forEach(k => { + PROMPTS[k] = { ...DEFAULT_PROMPTS[k] }; +}); + +// ================== 辅助函数 ================== +const wrap = (tag, content) => content ? `<${tag}>\n${content}\n` : ''; + +const buildWorldInfo = () => ` +{{description}}{$worldInfo} +玩家角色:{{user}} +{{persona}}`; + +const buildHistory = n => `\n{$history${n}}\n`; + +const buildNameList = (contacts, strangers) => { + const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)]; + return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : ''; +}; + +const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; + +// ================== 模板处理核心 ================== +function processTemplate(template, vars = {}) { + if (!template) return ''; + let result = String(template); + + // 基础变量 - 保持 {{user}} {{char}} 原样(由 ST 处理) + // 不替换,让酒馆的宏处理 + + // 场景变量 + const simpleVars = { + 'CONTACT_NAME': vars.contactName, + 'USER_MESSAGE': vars.userMessage, + 'TARGET_LOCATION': vars.targetLocation || vars.targetLocationName, + 'TARGET_LOCATION_TYPE': vars.targetLocationType, + 'TARGET_LOCATION_INFO': vars.targetLocationInfo, + 'STRANGER_NAME': vars.strangerName, + 'STRANGER_INFO': vars.strangerInfo, + 'PLAYER_REQUESTS': vars.playerRequests || '无特殊要求', + 'DEVIATION_SCORE': vars.deviationScore ?? 0, + 'STAGE': vars.stage ?? 0, + 'PREV_LOCATION': vars.prevLocationName, + 'PREV_LOCATION_INFO': vars.prevLocationInfo, + 'PLAYER_ACTION': vars.playerAction || '无特定意图', + 'CONVERSATION_TEXT': vars.conversationText, + 'LOCATION_NAME': vars.locationName || vars.playerLocation, + 'LOCATION_INFO': vars.locationInfo, + }; + + for (const [key, value] of Object.entries(simpleVars)) { + if (value !== undefined) { + result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value)); + } + } + + // 内容块 + // {{WORLD_INFO}} + result = result.replace(/\{\{WORLD_INFO\}\}/g, buildWorldInfo()); + + // {{HISTORY}} 或 {{HISTORY_N}} + result = result.replace(/\{\{HISTORY(?:_(\d+))?\}\}/g, (_, count) => { + const n = count ? parseInt(count, 10) : (vars.historyCount || 50); + return buildHistory(n); + }); + + // {{STORY_OUTLINE}} - 自动包裹,空则不输出 + if (result.includes('{{STORY_OUTLINE}}')) { + const so = vars.storyOutline; + const wrapped = so ? `${wrap('story_outline', so)}\n\n` : ''; + result = result.replace(/\{\{STORY_OUTLINE\}\}/g, wrapped); + } + + // {{SMS_HISTORY}} + if (result.includes('{{SMS_HISTORY}}')) { + const sh = vars.smsHistoryContent || buildSmsHistoryContent(vars.smsHistory); + result = result.replace(/\{\{SMS_HISTORY\}\}/g, sh); + } + + // {{EXISTING_SUMMARY}} + if (result.includes('{{EXISTING_SUMMARY}}')) { + const es = vars.existingSummaryContent || buildExistingSummaryContent(vars.existingSummary); + result = result.replace(/\{\{EXISTING_SUMMARY\}\}/g, es); + } + + // {{CHARACTER_CONTENT}} - 自动包裹 + if (result.includes('{{CHARACTER_CONTENT}}')) { + const cc = vars.characterContent; + const name = vars.contactName || '角色'; + const wrapped = cc ? `\n\n<${name}的人物设定>\n${cc}\n` : ''; + result = result.replace(/\{\{CHARACTER_CONTENT\}\}/g, wrapped); + } + + // {{CURRENT_WORLD_DATA}} + if (result.includes('{{CURRENT_WORLD_DATA}}')) { + const cwd = typeof vars.currentWorldData === 'string' + ? vars.currentWorldData + : JSON.stringify(vars.currentWorldData || {}, null, 2); + result = result.replace(/\{\{CURRENT_WORLD_DATA\}\}/g, cwd); + } + + // {{STEP1_DATA}} + if (result.includes('{{STEP1_DATA}}')) { + const s1 = typeof vars.step1Data === 'string' + ? vars.step1Data + : JSON.stringify(vars.step1Data || {}, null, 2); + result = result.replace(/\{\{STEP1_DATA\}\}/g, s1); + } + + // {{OUTDOOR_DESC}} + result = result.replace(/\{\{OUTDOOR_DESC\}\}/g, vars.outdoorDescription || '无大地图描述'); + + // {{CURRENT_LOCAL_MAP}} + if (result.includes('{{CURRENT_LOCAL_MAP}}')) { + const clm = typeof vars.currentLocalMap === 'string' + ? vars.currentLocalMap + : JSON.stringify(vars.currentLocalMap || {}, null, 2); + result = result.replace(/\{\{CURRENT_LOCAL_MAP\}\}/g, clm); + } + + // {{CURRENT_TIMELINE}} + if (result.includes('{{CURRENT_TIMELINE}}')) { + let tl = ''; + if (vars.currentTimeline) { + tl = `Stage ${vars.currentTimeline.stage}: ${vars.currentTimeline.state} - ${vars.currentTimeline.event}`; + } else { + tl = `Stage ${vars.stage ?? 0}`; + } + result = result.replace(/\{\{CURRENT_TIMELINE\}\}/g, tl); + } + + // {{EXISTING_NAMES}} + if (result.includes('{{EXISTING_NAMES}}')) { + const names = buildNameList(vars.existingContacts, vars.existingStrangers); + result = result.replace(/\{\{EXISTING_NAMES\}\}/g, names); + } + + // JSON 模板 {{JSON:xxx}} + result = result.replace(/\{\{JSON:(\w+)\}\}/gi, (_, key) => { + return JSON_TEMPLATES[key] || DEFAULT_JSON_TEMPLATES[key] || `{{JSON:${key}}}`; + }); + + return result; +} + +// ================== 辅助内容构建 ================== +export const buildSmsHistoryContent = t => + t ? `<已有短信>\n${t}\n` : '<已有短信>\n(空白,首次对话)\n'; + +export const buildExistingSummaryContent = t => + t ? `<已有总结>\n${t}\n` : '<已有总结>\n(空白,首次总结)\n'; + +// ================== 消息构建函数 ================== +function buildMessages(templateKey, vars) { + const prompts = PROMPTS[templateKey] || DEFAULT_PROMPTS[templateKey]; + if (!prompts) { + console.warn(`[StoryOutline] Unknown template key: ${templateKey}`); + return []; + } + + return [ + { role: 'user', content: processTemplate(prompts.u1, vars) }, + { role: 'assistant', content: processTemplate(prompts.a1, vars) }, + { role: 'user', content: processTemplate(prompts.u2, vars) }, + { role: 'assistant', content: processTemplate(prompts.a2, vars) } + ]; +} + +// ================== 导出的构建函数 ================== +export const buildSmsMessages = v => buildMessages('sms', v); +export const buildSummaryMessages = v => buildMessages('summary', v); +export const buildInviteMessages = v => buildMessages('invite', v); +export const buildNpcGenerationMessages = v => buildMessages('npc', v); +export const buildExtractStrangersMessages = v => buildMessages('stranger', v); + +export const buildWorldGenStep1Messages = v => buildMessages('worldGenStep1', v); +export const buildWorldGenStep2Messages = v => buildMessages('worldGenStep2', v); + +export const buildWorldSimMessages = v => { + const key = v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim'; + return buildMessages(key, v); +}; + +export const buildSceneSwitchMessages = v => { + const key = v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch'; + return buildMessages(key, v); +}; + +export const buildLocalMapGenMessages = v => buildMessages('localMapGen', v); +export const buildLocalMapRefreshMessages = v => buildMessages('localMapRefresh', v); +export const buildLocalSceneGenMessages = v => buildMessages('localSceneGen', v); + +// ================== 配置管理 ================== +const safeJson = fn => { try { return fn(); } catch { return null; } }; + +const loadFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY))); + +const saveToStorage = cfg => { + try { + localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); + } catch (e) { + console.warn('[StoryOutline] Failed to save prompt config:', e); + } +}; + +export const getPromptConfigPayload = () => ({ + current: { + jsonTemplates: JSON_TEMPLATES, + prompts: PROMPTS + }, + defaults: { + jsonTemplates: DEFAULT_JSON_TEMPLATES, + prompts: DEFAULT_PROMPTS + } +}); + +export const setPromptConfig = (cfg, persist = false) => { + if (cfg?.jsonTemplates) { + JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates }; + } + if (cfg?.prompts) { + // 合并用户自定义的 prompts + Object.keys(DEFAULT_PROMPTS).forEach(k => { + if (cfg.prompts[k]) { + PROMPTS[k] = { ...DEFAULT_PROMPTS[k], ...cfg.prompts[k] }; + } else { + PROMPTS[k] = { ...DEFAULT_PROMPTS[k] }; + } + }); + } + + if (persist) { + saveToStorage({ jsonTemplates: JSON_TEMPLATES, prompts: PROMPTS }); + } + + return getPromptConfigPayload().current; +}; + +export const reloadPromptConfigFromStorage = () => { + const saved = loadFromStorage(); + if (saved) { + setPromptConfig(saved, false); + } + return getPromptConfigPayload().current; +}; + +// 初始化时加载 +reloadPromptConfigFromStorage(); + +// ================== NPC 格式化 ================== +function jsonToYaml(data, indent = 0) { + const sp = ' '.repeat(indent); + if (data === null || data === undefined) return ''; + if (typeof data !== 'object') return String(data); + if (Array.isArray(data)) { + return data.map(item => typeof item === 'object' && item !== null + ? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}` + : `${sp}- ${item}` + ).join('\n'); + } + return Object.entries(data).map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value) && !value.length) return `${sp}${key}: []`; + if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`; + return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`; + } + return `${sp}${key}: ${value}`; + }).join('\n'); +} + +export function formatNpcToWorldbookContent(npc) { + return jsonToYaml(npc); +} + +// ================== Overlay HTML ================== +const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; + +export const buildOverlayHtml = src => ``; + export const MOBILE_LAYOUT_STYLE = 'position:absolute!important;left:0!important;right:0!important;top:0!important;bottom:auto!important;width:100%!important;height:350px!important;transform:none!important;z-index:1!important;pointer-events:auto!important;border-radius:0 0 16px 16px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; - -export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; + +export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; diff --git a/modules/story-outline/story-outline.html b/modules/story-outline/story-outline.html index 2a2a476..2b5f50a 100644 --- a/modules/story-outline/story-outline.html +++ b/modules/story-outline/story-outline.html @@ -1,1776 +1,1905 @@ - - - - - - 小白板 - - - - - -
-
-
-
- - - -
-
- -
- - -
-
小白板预测试
- - - -
- - -
- -
- -

最新消息

-
-

当前状态

-
尚未生成世界数据...
-

行动指南

-
等待世界生成...
-
-
- - -
-
-
- - 大地图 - - -
-
-
-
-
100%
-
-
-
-
← 返回
-
-
-
-
- - -
-
-
-
陌路人
-
联络人
-
- - -
-
-
-
-
- - -
-
-
-
-
- - - -
-
-
-
- - - -
-
- - -
-
-
-
- 场景描述 - -
-
-
-
- - -
-
-
-
-
-
-
← 返回
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 小白板 + + + + + +
+
+
+
+ + + +
+
+ +
+ + +
+
小白板预测试
+ + + +
+ + +
+
+ +

最新消息

+
+

当前状态

+
尚未生成世界数据...
+

行动指南

+
等待世界生成...
+
+
+ +
+
+
+ + 大地图 + + +
+
+
+
+
100%
+
+
+
+
← 返回
+
+
+
+
+ +
+
+
+
陌路人
+
联络人
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+ + + +
+
+ + +
+
+
+
+ 场景描述 + +
+
+
+
+ + +
+
+
+
+
+
+
← 返回
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/story-outline/story-outline.js b/modules/story-outline/story-outline.js index 406f7c3..66e4dae 100644 --- a/modules/story-outline/story-outline.js +++ b/modules/story-outline/story-outline.js @@ -1,1204 +1,1093 @@ -/** - * ============================================================================ - * Story Outline 模块 - 小白板 - * ============================================================================ - * 功能:生成和管理RPG式剧情世界,提供地图导航、NPC管理、短信系统、世界推演 - * - * 分区: - * 1. 导入与常量 - * 2. 通用工具 - * 3. JSON解析 - * 4. 存储管理 - * 5. LLM调用 - * 6. 世界书操作 - * 7. 剧情注入 - * 8. iframe通讯 - * 9. 请求处理器 - * 10. UI管理 - * 11. 事件与初始化 - * ============================================================================ - */ - -// ==================== 1. 导入与常量 ==================== -import { extension_settings, saveMetadataDebounced } from "../../../../../extensions.js"; -import { chat_metadata, name1, processCommands, eventSource, event_types as st_event_types } from "../../../../../../script.js"; -import { loadWorldInfo, saveWorldInfo, world_names, world_info } from "../../../../../world-info.js"; -import { getContext } from "../../../../../st-context.js"; -import { streamingGeneration } from "../streaming-generation.js"; -import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; -import { createModuleEvents, event_types } from "../../core/event-manager.js"; -import { promptManager } from "../../../../../openai.js"; -import { - buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent, - buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages, - buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages, - buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages, - buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig -} from "./story-outline-prompt.js"; - -const events = createModuleEvents('storyOutline'); -const IFRAME_PATH = `${extensionFolderPath}/modules/story-outline/story-outline.html`; -const STORAGE_KEYS = { global: 'LittleWhiteBox_StoryOutline_GlobalSettings', comm: 'LittleWhiteBox_StoryOutline_CommSettings' }; -const STORY_OUTLINE_ID = 'lwb_story_outline'; -const CHAR_CARD_UID = '__CHARACTER_CARD__'; -const DEBUG_KEY = 'LittleWhiteBox_StoryOutline_Debug'; - -let overlayCreated = false, frameReady = false, currentMesId = null, pendingMsgs = [], presetCleanup = null, step1Cache = null; - -// ==================== 2. 通用工具 ==================== - -/** 移动端检测 */ -const isMobile = () => window.innerWidth < 550; - -/** 安全执行函数 */ -const safe = fn => { try { return fn(); } catch { return null; } }; -const isDebug = () => { - try { return localStorage.getItem(DEBUG_KEY) === '1'; } catch { return false; } -}; - -/** localStorage读写 */ -const getStore = (k, def) => safe(() => JSON.parse(localStorage.getItem(k))) || def; -const setStore = (k, v) => safe(() => localStorage.setItem(k, JSON.stringify(v))); - -/** 随机范围 */ -const randRange = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a; - -/** - * 修复单个 JSON 字符串的语法问题 - * 仅在已提取的候选上调用,不做全局破坏性操作 - */ -function fixJson(s) { - if (!s || typeof s !== 'string') return s; - - let r = s.trim() - // 统一引号:只转换弯引号 - .replace(/[""]/g, '"').replace(/['']/g, "'") - // 修复键名后的错误引号:如 "key': → "key": - .replace(/"([^"']+)'[\s]*:/g, '"$1":') - .replace(/'([^"']+)"[\s]*:/g, '"$1":') - // 修复单引号包裹的完整值:: 'value' → : "value" - .replace(/:[\s]*'([^']*)'[\s]*([,}\]])/g, ':"$1"$2') - // 修复无引号的键名 - .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":') - // 移除尾随逗号 - .replace(/,[\s\n]*([}\]])/g, '$1') - // 修复 undefined 和 NaN - .replace(/:\s*undefined\b/g, ': null').replace(/:\s*NaN\b/g, ': null'); - - // 补全未闭合的括号 - let braces = 0, brackets = 0, inStr = false, esc = false; - for (const c of r) { - if (esc) { esc = false; continue; } - if (c === '\\' && inStr) { esc = true; continue; } - if (c === '"') { inStr = !inStr; continue; } - if (!inStr) { - if (c === '{') braces++; else if (c === '}') braces--; - if (c === '[') brackets++; else if (c === ']') brackets--; - } - } - while (braces-- > 0) r += '}'; - while (brackets-- > 0) r += ']'; - return r; -} - -/** - * 从输入中提取 JSON(非破坏性扫描版) - * 策略: - * 1. 直接在原始字符串中扫描所有 {...} 结构 - * 2. 对每个候选单独清洗和解析 - * 3. 按有效属性评分,返回最佳结果 - */ -function extractJson(input, isArray = false) { - if (!input) return null; - - // 处理已经是对象的输入 - if (typeof input === 'object' && input !== null) { - if (isArray && Array.isArray(input)) return input; - if (!isArray && !Array.isArray(input)) { - const content = input.choices?.[0]?.message?.content - ?? input.choices?.[0]?.message?.reasoning_content - ?? input.content ?? input.reasoning_content; - if (content != null) return extractJson(String(content).trim(), isArray); - if (!input.choices) return input; - } - return null; - } - - // 预处理:只做最基本的清理 - const str = String(input).trim() - .replace(/^\uFEFF/, '') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - .replace(/\r\n?/g, '\n'); - if (!str) return null; - - const tryParse = s => { try { return JSON.parse(s); } catch { return null; } }; - const ok = (o, arr) => o != null && (arr ? Array.isArray(o) : typeof o === 'object' && !Array.isArray(o)); - - // 评分函数:meta=10, world/maps=5, 其他=3 - const score = o => (o?.meta ? 10 : 0) + (o?.world ? 5 : 0) + (o?.maps ? 5 : 0) + - (o?.truth ? 3 : 0) + (o?.onion_layers ? 3 : 0) + (o?.atmosphere ? 3 : 0) + (o?.trajectory ? 3 : 0) + (o?.user_guide ? 3 : 0); - - // 1. 直接尝试解析(最理想情况) - let r = tryParse(str); - if (ok(r, isArray) && score(r) > 0) return r; - - // 2. 扫描所有 {...} 或 [...] 结构 - const open = isArray ? '[' : '{'; - const candidates = []; - - for (let i = 0; i < str.length; i++) { - if (str[i] !== open) continue; - - // 括号匹配找闭合位置 - let depth = 0, inStr = false, esc = false; - for (let j = i; j < str.length; j++) { - const c = str[j]; - if (esc) { esc = false; continue; } - if (c === '\\' && inStr) { esc = true; continue; } - if (c === '"') { inStr = !inStr; continue; } - if (inStr) continue; - if (c === '{' || c === '[') depth++; - else if (c === '}' || c === ']') depth--; - if (depth === 0) { - candidates.push({ start: i, end: j, text: str.slice(i, j + 1) }); - i = j; // 跳过已处理的部分 - break; - } - } - } - - // 3. 按长度排序(大的优先,更可能是完整对象) - candidates.sort((a, b) => b.text.length - a.text.length); - - // 4. 尝试解析每个候选,记录最佳结果 - let best = null, bestScore = -1; - - for (const { text } of candidates) { - // 直接解析 - r = tryParse(text); - if (ok(r, isArray)) { - const s = score(r); - if (s > bestScore) { best = r; bestScore = s; } - if (s >= 10) return r; // 有 meta 就直接返回 - continue; - } - - // 修复后解析 - const fixed = fixJson(text); - r = tryParse(fixed); - if (ok(r, isArray)) { - const s = score(r); - if (s > bestScore) { best = r; bestScore = s; } - if (s >= 10) return r; - } - } - - // 5. 返回最佳结果 - if (best) return best; - - // 6. 最后尝试:取第一个 { 到最后一个 } 之间的内容 - const firstBrace = str.indexOf('{'); - const lastBrace = str.lastIndexOf('}'); - if (!isArray && firstBrace !== -1 && lastBrace > firstBrace) { - const chunk = str.slice(firstBrace, lastBrace + 1); - r = tryParse(chunk) || tryParse(fixJson(chunk)); - if (ok(r, isArray)) return r; - } - - return null; -} - -export { extractJson }; - -// ==================== 4. 存储管理 ==================== - -/** 获取扩展设置 */ -const getSettings = () => { const e = extension_settings[EXT_ID] ||= {}; e.storyOutline ||= { enabled: true }; return e; }; - -/** 获取剧情大纲存储 */ -function getOutlineStore() { - if (!chat_metadata) return null; - const ext = chat_metadata.extensions ||= {}, lwb = ext[EXT_ID] ||= {}; - return lwb.storyOutline ||= { - mapData: null, stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: 5, playerLocation: '家', - outlineData: { meta: null, world: null, outdoor: null, indoor: null, sceneSetup: null, strangers: null, contacts: null }, - dataChecked: { meta: true, world: true, outdoor: true, indoor: true, sceneSetup: true, strangers: false, contacts: false, characterContactSms: false } - }; -} - -/** 全局/通讯设置读写 */ -const getGlobalSettings = () => getStore(STORAGE_KEYS.global, { apiUrl: '', apiKey: '', model: '', mode: 'assist' }); -const saveGlobalSettings = s => setStore(STORAGE_KEYS.global, s); -const getCommSettings = () => ({ historyCount: 50, npcPosition: 0, npcOrder: 100, ...getStore(STORAGE_KEYS.comm, {}) }); -const saveCommSettings = s => setStore(STORAGE_KEYS.comm, s); - -/** 获取角色卡信息 */ -function getCharInfo() { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - return { - name: char?.name || char?.data?.name || char?.avatar || '角色卡', - desc: String((char?.description ?? char?.data?.description ?? '') || '').trim() || '{{description}}' - }; -} - -/** 获取角色卡短信历史 */ -function getCharSmsHistory() { - if (!chat_metadata) return null; - const root = chat_metadata.LittleWhiteBox_StoryOutline ||= {}; - const h = root.characterContactSmsHistory ||= { messages: [], summarizedCount: 0, summaries: {} }; - h.messages ||= []; h.summarizedCount ||= 0; h.summaries ||= {}; - return h; -} - -// ==================== 5. LLM调用 ==================== - - -/** 调用LLM */ -async function callLLM(promptOrMsgs, useRaw = false) { - const { apiUrl, apiKey, model } = getGlobalSettings(); - - const normalize = r => { - if (r == null) return ''; - if (typeof r === 'string') return r; - if (typeof r === 'object') { - if (r.data && typeof r.data === 'object') return normalize(r.data); - if (typeof r.text === 'string') return r.text; - if (typeof r.response === 'string') return r.response; - const inner = r.content?.trim?.() || r.reasoning_content?.trim?.() || r.choices?.[0]?.message?.content?.trim?.() || r.choices?.[0]?.message?.reasoning_content?.trim?.() || null; - if (inner != null) return String(inner); - return safe(() => JSON.stringify(r)) || String(r); - } - return String(r); - }; - - // 构建基础选项 - const opts = { nonstream: 'true', lock: 'on' }; - if (apiUrl?.trim()) Object.assign(opts, { api: 'openai', apiurl: apiUrl.trim(), ...(apiKey && { apipassword: apiKey }), ...(model && { model }) }); - - if (useRaw) { - const messages = Array.isArray(promptOrMsgs) - ? promptOrMsgs - : [{ role: 'user', content: String(promptOrMsgs || '').trim() }]; - - // 直接把消息转成 top 参数格式,不做预处理 - // {$worldInfo} 和 {$historyN} 由 xbgenrawCommand 内部处理 - const roleMap = { user: 'user', assistant: 'assistant', system: 'sys' }; - const topParts = messages - .filter(m => m?.role && typeof m.content === 'string' && m.content.trim()) - .map(m => { - const role = roleMap[m.role] || m.role; - return `${role}={${m.content}}`; - }); - const topParam = topParts.join(';'); - - opts.top = topParam; - // 不设置 addon,让 xbgenrawCommand 自己处理 {$worldInfo} 占位符替换 - - const raw = await streamingGeneration.xbgenrawCommand(opts, ''); - const text = normalize(raw).trim(); - - if (isDebug()) { - try { - console.groupCollapsed('[StoryOutline] callLLM(useRaw via xbgenrawCommand)'); - console.log('opts.top.length', topParam.length); - console.log('raw', raw); - console.log('normalized.length', text.length); - console.groupEnd(); - } catch { } - } - return text; - } - - opts.as = 'user'; - opts.position = 'history'; - return normalize(await streamingGeneration.xbgenCommand(opts, promptOrMsgs)).trim(); -} - -/** 调用LLM并解析JSON */ -async function callLLMJson({ messages, useRaw = true, isArray = false, validate }) { - try { - const result = await callLLM(messages, useRaw); - if (isDebug()) { - try { - const s = String(result ?? ''); - console.groupCollapsed('[StoryOutline] callLLMJson'); - console.log({ useRaw, isArray, length: s.length }); - console.log('result.head', s.slice(0, 500)); - console.log('result.tail', s.slice(Math.max(0, s.length - 500))); - console.groupEnd(); - } catch { } - } - const parsed = extractJson(result, isArray); - if (isDebug()) { - try { - console.groupCollapsed('[StoryOutline] extractJson'); - console.log('parsed', parsed); - console.log('validate', !!(parsed && validate?.(parsed))); - console.groupEnd(); - } catch { } - } - if (parsed && validate(parsed)) return parsed; - } catch { } - return null; -} - -// ==================== 6. 世界书操作 ==================== - -/** 获取角色卡绑定的世界书 */ -async function getCharWorldbooks() { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - if (!char) return []; - const books = [], primary = char.data?.extensions?.world; - if (primary && world_names?.includes(primary)) books.push(primary); - (world_info?.charLore || []).find(e => e.name === char.avatar)?.extraBooks?.forEach(b => { - if (world_names?.includes(b) && !books.includes(b)) books.push(b); - }); - return books; -} - -/** 根据UID查找条目 */ -async function findEntry(uid) { - const uidNum = parseInt(uid, 10); - if (isNaN(uidNum)) return null; - for (const book of await getCharWorldbooks()) { - const data = await loadWorldInfo(book); - if (data?.entries?.[uidNum]) return { bookName: book, entry: data.entries[uidNum], uidNumber: uidNum, worldData: data }; - } - return null; -} - -/** 根据名称搜索条目 */ -async function searchEntry(name) { - const nl = (name || '').toLowerCase().trim(); - for (const book of await getCharWorldbooks()) { - const data = await loadWorldInfo(book); - if (!data?.entries) continue; - for (const [uid, entry] of Object.entries(data.entries)) { - const keys = Array.isArray(entry.key) ? entry.key : []; - if (keys.some(k => { const kl = (k || '').toLowerCase().trim(); return kl === nl || kl.includes(nl) || nl.includes(kl); })) - return { uid: String(uid), bookName: book, entry }; - } - } - return null; -} - -// ==================== 7. 剧情注入 ==================== - -/** 获取可见洋葱层级 */ -const getVisibleLayers = stage => ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].slice(0, Math.min(Math.max(0, stage), 3) + 2); - -/** 格式化剧情数据为提示词 */ -function formatOutlinePrompt() { - const store = getOutlineStore(); - if (!store?.outlineData) return ""; - - const { outlineData: d, dataChecked: c, playerLocation } = store, stage = store.stage ?? 0; - let text = "## Story Outline (剧情数据)\n\n", has = false; - - // 世界真相 - if (c?.meta && d.meta?.truth) { - has = true; - text += "### 世界真相 (World Truth)\n> 注意:以下信息仅供生成逻辑参考,不可告知玩家。\n"; - if (d.meta.truth.background) text += `* 背景真相: ${d.meta.truth.background}\n`; - const dr = d.meta.truth.driver; - if (dr) { if (dr.source) text += `* 驱动: ${dr.source}\n`; if (dr.target_end) text += `* 目的: ${dr.target_end}\n`; if (dr.tactic) text += `* 当前手段: ${dr.tactic}\n`; } - - // 当前气氛 - const atm = d.meta.atmosphere?.current; - if (atm) { - if (atm.environmental) text += `* 当前气氛: ${atm.environmental}\n`; - if (atm.npc_attitudes) text += `* NPC态度: ${atm.npc_attitudes}\n`; - } - - const onion = d.meta.onion_layers || d.meta.truth.onion_layers; - if (onion) { - text += "* 当前可见层级:\n"; - getVisibleLayers(stage).forEach(k => { - const l = onion[k]; if (!l || !Array.isArray(l) || !l.length) return; - const name = k.replace(/_/g, ' - '); - l.forEach(i => { text += ` - [${name}] ${i.desc}: ${i.logic}\n`; }); - }); - } - text += "\n"; - } - - // 世界资讯 - if (c?.world && d.world?.news?.length) { has = true; text += "### 世界资讯 (News)\n"; d.world.news.forEach(n => { text += `* ${n.title}: ${n.content}\n`; }); text += "\n"; } - - // 环境信息 - let mapC = "", locNode = null; - if (c?.outdoor && d.outdoor) { - if (d.outdoor.description) mapC += `> 大地图环境: ${d.outdoor.description}\n`; - if (playerLocation && d.outdoor.nodes?.length) locNode = d.outdoor.nodes.find(n => n.name === playerLocation); - } - if (!locNode && c?.indoor && d.indoor?.nodes?.length && playerLocation) locNode = d.indoor.nodes.find(n => n.name === playerLocation); - const indoorMap = (c?.indoor && playerLocation && d.indoor && typeof d.indoor === 'object' && !Array.isArray(d.indoor)) ? d.indoor[playerLocation] : null; - const locText = indoorMap?.description || locNode?.info || ''; - if (playerLocation && locText) mapC += `\n> 当前地点 (${playerLocation}):\n${locText}\n`; - if (c?.indoor && d.indoor && !locNode && !indoorMap && d.indoor.description) { mapC += d.indoor.name ? `\n> 当前地点: ${d.indoor.name}\n` : "\n> 局部区域:\n"; mapC += `${d.indoor.description}\n`; } - if (mapC) { has = true; text += `### 环境信息 (Environment)\n${mapC}\n`; } - - // 周边人物 - let charC = ""; - if (c?.contacts && d.contacts?.length) { charC += "* 联络人:\n"; d.contacts.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } - if (c?.strangers && d.strangers?.length) { charC += "* 陌路人:\n"; d.strangers.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } - if (charC) { has = true; text += `### 周边人物 (Characters)\n${charC}\n`; } - - // 当前剧情 - if (c?.sceneSetup && d.sceneSetup) { - const ss = d.sceneSetup.sideStory || d.sceneSetup.side_story || d.sceneSetup; - if (ss && (ss.surface || ss.inner)) { has = true; text += "### 当前剧情 (Current Scene)\n"; if (ss.surface) text += `* 表象: ${ss.surface}\n`; if (ss.inner) text += `* 里层 (潜台词): ${ss.inner}\n`; text += "\n"; } - } - - // 角色卡短信 - if (c?.characterContactSms) { - const { name: charName } = getCharInfo(), hist = getCharSmsHistory(); - const sums = hist?.summaries || {}, sumKeys = Object.keys(sums).filter(k => k !== '_count').sort((a, b) => a - b); - const msgs = hist?.messages || [], sc = hist?.summarizedCount || 0, rem = msgs.slice(sc); - if (sumKeys.length || rem.length) { - has = true; text += `### ${charName}短信记录\n`; - if (sumKeys.length) text += `[摘要] ${sumKeys.map(k => sums[k]).join(';')}\n`; - if (rem.length) text += rem.map(m => `${m.type === 'sent' ? '{{user}}' : charName}:${m.text}`).join('\n') + "\n"; - text += "\n"; - } - } - - return has ? text.trim() : ""; -} - -/** 确保剧情大纲Prompt存在 */ -function ensurePrompt() { - if (!promptManager) return false; - let prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - if (!prompt) { - promptManager.addPrompt({ identifier: STORY_OUTLINE_ID, name: '剧情地图', role: 'system', content: '', system_prompt: false, marker: false, extension: true }, STORY_OUTLINE_ID); - prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - } - const char = promptManager.activeCharacter; - if (!char) return true; - const order = promptManager.getPromptOrderForCharacter(char); - const exists = order.some(e => e.identifier === STORY_OUTLINE_ID); - if (!exists) { const idx = order.findIndex(e => e.identifier === 'charDescription'); order.splice(idx !== -1 ? idx : 0, 0, { identifier: STORY_OUTLINE_ID, enabled: true }); } - else { const entry = order.find(e => e.identifier === STORY_OUTLINE_ID); if (entry && !entry.enabled) entry.enabled = true; } - promptManager.render?.(false); - return true; -} - -/** 更新剧情大纲Prompt内容 */ -function updatePromptContent() { - if (!promptManager) return; - if (!getSettings().storyOutline?.enabled) { removePrompt(); return; } - ensurePrompt(); - const store = getOutlineStore(), prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - if (!prompt) return; - const { dataChecked } = store || {}; - const hasAny = dataChecked && Object.values(dataChecked).some(v => v === true); - prompt.content = (!hasAny || !store) ? '' : (formatOutlinePrompt() || ''); - promptManager.render?.(false); -} - -/** 移除剧情大纲Prompt */ -function removePrompt() { - if (!promptManager) return; - const prompts = promptManager.serviceSettings?.prompts; - if (prompts) { const idx = prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (idx !== -1) prompts.splice(idx, 1); } - const orders = promptManager.serviceSettings?.prompt_order; - if (orders) orders.forEach(cfg => { if (cfg?.order) { const idx = cfg.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (idx !== -1) cfg.order.splice(idx, 1); } }); - promptManager.render?.(false); -} - -/** 设置ST预设事件监听 */ -function setupSTEvents() { - if (presetCleanup) return; - const onChanged = () => { if (getSettings().storyOutline?.enabled) setTimeout(() => { ensurePrompt(); updatePromptContent(); }, 100); }; - const onExport = preset => { - if (!preset) return; - if (preset.prompts) { const i = preset.prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (i !== -1) preset.prompts.splice(i, 1); } - if (preset.prompt_order) preset.prompt_order.forEach(c => { if (c?.order) { const i = c.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (i !== -1) c.order.splice(i, 1); } }); - }; - eventSource.on(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); - eventSource.on(st_event_types.OAI_PRESET_EXPORT_READY, onExport); - presetCleanup = () => { try { eventSource.removeListener(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); } catch { } try { eventSource.removeListener(st_event_types.OAI_PRESET_EXPORT_READY, onExport); } catch { } }; -} - -const injectOutline = () => updatePromptContent(); - -// ==================== 8. iframe通讯 ==================== - -/** 发送消息到iframe */ -function postFrame(payload) { - const iframe = document.getElementById("xiaobaix-story-outline-iframe"); - if (!iframe?.contentWindow || !frameReady) { pendingMsgs.push(payload); return; } - iframe.contentWindow.postMessage({ source: "LittleWhiteBox", ...payload }, "*"); -} - -const flushPending = () => { if (!frameReady) return; const f = document.getElementById("xiaobaix-story-outline-iframe"); pendingMsgs.forEach(p => f?.contentWindow?.postMessage({ source: "LittleWhiteBox", ...p }, "*")); pendingMsgs = []; }; - -/** 发送设置到iframe */ -function sendSettings() { - const store = getOutlineStore(), { name: charName, desc: charDesc } = getCharInfo(); - postFrame({ - type: "LOAD_SETTINGS", globalSettings: getGlobalSettings(), commSettings: getCommSettings(), - stage: store?.stage ?? 0, deviationScore: store?.deviationScore ?? 0, simulationProgress: store?.simulationProgress ?? 0, - simulationTarget: store?.simulationTarget ?? 5, playerLocation: store?.playerLocation ?? '家', - dataChecked: store?.dataChecked || {}, outlineData: store?.outlineData || {}, promptConfig: getPromptConfigPayload?.(), - characterCardName: charName, characterCardDescription: charDesc, - characterContactSmsHistory: getCharSmsHistory() - }); -} - -const loadAndSend = () => { const s = getOutlineStore(); if (s?.mapData) postFrame({ type: "LOAD_MAP_DATA", mapData: s.mapData }); sendSettings(); }; - -// ==================== 9. 请求处理器 ==================== - -const reply = (type, reqId, data) => postFrame({ type, requestId: reqId, ...data }); -const replyErr = (type, reqId, err) => reply(type, reqId, { error: err }); - -/** 获取当前气氛 */ -function getAtmosphere(store) { - return store?.outlineData?.meta?.atmosphere?.current || null; -} - -/** 合并世界推演数据 */ -function mergeSimData(orig, upd) { - if (!upd) return orig; - const r = JSON.parse(JSON.stringify(orig || {})); - const um = upd?.meta || {}, ut = um.truth || upd?.truth, uo = um.onion_layers || ut?.onion_layers; - const ua = um.atmosphere || upd?.atmosphere, utr = um.trajectory || upd?.trajectory; - r.meta = r.meta || {}; r.meta.truth = r.meta.truth || {}; - if (ut?.driver?.tactic) r.meta.truth.driver = { ...r.meta.truth.driver, tactic: ut.driver.tactic }; - if (uo) { ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(l => { const v = uo[l]; if (Array.isArray(v) && v.length) { r.meta.onion_layers = r.meta.onion_layers || {}; r.meta.onion_layers[l] = v; } }); if (r.meta.truth?.onion_layers) delete r.meta.truth.onion_layers; } - if (um.user_guide || upd?.user_guide) r.meta.user_guide = um.user_guide || upd.user_guide; - // 更新 atmosphere - if (ua) { r.meta.atmosphere = ua; } - // 更新 trajectory - if (utr) { r.meta.trajectory = utr; } - if (upd?.world) r.world = upd.world; - if (upd?.maps?.outdoor) { r.maps = r.maps || {}; r.maps.outdoor = r.maps.outdoor || {}; if (upd.maps.outdoor.description) r.maps.outdoor.description = upd.maps.outdoor.description; if (Array.isArray(upd.maps.outdoor.nodes)) { const on = r.maps.outdoor.nodes || []; upd.maps.outdoor.nodes.forEach(n => { const i = on.findIndex(x => x.name === n.name); if (i >= 0) on[i] = { ...n }; else on.push(n); }); r.maps.outdoor.nodes = on; } } - return r; -} - -/** 检查自动推演 */ -async function checkAutoSim(reqId) { - const store = getOutlineStore(); - if (!store || (store.simulationProgress || 0) < (store.simulationTarget ?? 5)) return; - const data = { meta: store.outlineData?.meta || {}, world: store.outlineData?.world || null, maps: { outdoor: store.outlineData?.outdoor || null, indoor: store.outlineData?.indoor || null } }; - await handleSimWorld({ requestId: `wsim_auto_${Date.now()}`, currentData: JSON.stringify(data), isAuto: true }); -} - -// 验证器 -const V = { - sum: o => o?.summary, npc: o => o?.name && o?.aliases, arr: o => Array.isArray(o), - scene: o => !!o?.review?.deviation && !!(o?.local_map || o?.scene_setup?.local_map), - lscene: o => !!o?.side_story, inv: o => typeof o?.invite === 'boolean' && o?.reply, - sms: o => typeof o?.reply === 'string' && o.reply.length > 0, - wg1: d => !!d && typeof d === 'object', // 只要是对象就行,后续会 normalize - wg2: d => !!(d?.world && (d?.maps || d?.world?.maps)?.outdoor), - wga: d => !!(d?.world && d?.maps?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object', - lm: o => !!o?.inside?.name && !!o?.inside?.description -}; - -// --- 处理器 --- - -async function handleFetchModels({ apiUrl, apiKey }) { - try { - let models = []; - if (!apiUrl) { - for (const ep of ['/api/backends/chat-completions/models', '/api/openai/models']) { - try { const r = await fetch(ep, { headers: { 'Content-Type': 'application/json' } }); if (r.ok) { const j = await r.json(); models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); if (models.length) break; } } catch { } - } - if (!models.length) throw new Error('无法从酒馆获取模型列表'); - } else { - const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; - const r = await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const j = await r.json(); - models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); - } - postFrame({ type: "FETCH_MODELS_RESULT", models }); - } catch (e) { postFrame({ type: "FETCH_MODELS_RESULT", error: e.message }); } -} - -async function handleTestConn({ apiUrl, apiKey, model }) { - try { - if (!apiUrl) { for (const ep of ['/api/backends/chat-completions/status', '/api/openai/models', '/api/backends/chat-completions/models']) { try { if ((await fetch(ep, { headers: { 'Content-Type': 'application/json' } })).ok) { postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); return; } } catch { } } throw new Error('无法连接到酒馆API'); } - const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; - if (!(await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h })).ok) throw new Error('连接失败'); - postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); - } catch (e) { postFrame({ type: "TEST_CONN_RESULT", success: false, message: `连接失败: ${e.message}` }); } -} - -async function handleCheckUid({ uid, requestId }) { - const num = parseInt(uid, 10); - if (!uid?.trim() || isNaN(num)) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, isNaN(num) ? 'UID必须是数字' : '请输入有效的UID'); - const books = await getCharWorldbooks(); - if (!books.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, '当前角色卡没有绑定世界书'); - for (const book of books) { - const data = await loadWorldInfo(book), entry = data?.entries?.[num]; - if (entry) { - const keys = Array.isArray(entry.key) ? entry.key : []; - if (!keys.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在「${book}」中找到条目 UID ${uid},但没有主要关键字`); - return reply("CHECK_WORLDBOOK_UID_RESULT", requestId, { primaryKeys: keys, worldbook: book, comment: entry.comment || '' }); - } - } - replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在角色卡绑定的世界书中未找到 UID 为 ${uid} 的条目`); -} - -async function handleSendSms({ requestId, contactName, worldbookUid, userMessage, chatHistory, summarizedCount }) { - try { - const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; - let charContent = '', existSum = {}, sc = summarizedCount || 0; - - if (worldbookUid === CHAR_CARD_UID) { - charContent = getCharInfo().desc; - const h = getCharSmsHistory(); existSum = h?.summaries || {}; sc = summarizedCount ?? h?.summarizedCount ?? 0; - } else if (worldbookUid) { - const e = await findEntry(worldbookUid); - if (e?.entry) { - const c = e.entry.content || '', si = c.indexOf('[SMS_HISTORY_START]'); - charContent = si !== -1 ? c.substring(0, si).trim() : c; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; - if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } - } - } - - let histText = ''; - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - if (sumKeys.length) histText = `[之前的对话摘要] ${sumKeys.map(k => existSum[k]).join(';')}\n\n`; - if (chatHistory?.length > 1) { const msgs = chatHistory.slice(sc, -1); if (msgs.length) histText += msgs.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); } - - const msgs = buildSmsMessages({ contactName, userName, storyOutline: formatOutlinePrompt(), historyCount: getCommSettings().historyCount || 50, smsHistoryContent: buildSmsHistoryContent(histText), userMessage, characterContent: charContent }); - const parsed = await callLLMJson({ messages: msgs, validate: V.sms }); - reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' }); - } catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleLoadSmsHistory({ worldbookUid }) { - if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: h?.messages || [], summarizedCount: h?.summarizedCount || 0 }); } - const store = getOutlineStore(), contact = store?.outlineData?.contacts?.find(c => c.worldbookUid === worldbookUid); - if (contact?.smsHistory?.messages?.length) return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: contact.smsHistory.messages, summarizedCount: contact.smsHistory.summarizedCount || 0 }); - const e = await findEntry(worldbookUid); let msgs = []; - if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); p?.forEach?.(i => { if (typeof i === 'string' && !i.startsWith('SMS_summary:')) { const idx = i.indexOf(':'); if (idx > 0) msgs.push({ type: i.substring(0, idx) === '{{user}}' ? 'sent' : 'received', text: i.substring(idx + 1) }); } }); } } - postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: msgs, summarizedCount: 0 }); -} - -async function handleSaveSmsHistory({ worldbookUid, messages, contactName, summarizedCount }) { - if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); if (!h) return; h.messages = Array.isArray(messages) ? messages : []; h.summarizedCount = summarizedCount || 0; if (!h.messages.length) { h.summarizedCount = 0; h.summaries = {}; } saveMetadataDebounced?.(); return; } - const e = await findEntry(worldbookUid); if (!e) return; - const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; let existSum = ''; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; - if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); existSum = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')) || ''; c = c.substring(0, s).trimEnd() + c.substring(ed + 17); } - if (messages?.length) { const sc = summarizedCount || 0, simp = messages.slice(sc).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); const arr = existSum ? [existSum, ...simp] : simp; c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; } - en.content = c.trim(); await saveWorldInfo(bookName, worldData); -} - -async function handleCompressSms({ requestId, worldbookUid, messages, contactName, summarizedCount }) { - const sc = summarizedCount || 0; - try { - const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; - let e = null, existSum = {}; - - if (worldbookUid === CHAR_CARD_UID) { - const h = getCharSmsHistory(); existSum = h?.summaries || {}; - const keep = 4, toEnd = Math.max(sc, (messages?.length || 0) - keep); - if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); - const toSum = (messages || []).slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); - const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); - const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); - const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); - const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; - existSum[String(nextK)] = sum; - if (h) { h.messages = Array.isArray(messages) ? messages : (h.messages || []); h.summarizedCount = toEnd; h.summaries = existSum; saveMetadataDebounced?.(); } - return reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: toEnd }); - } - - e = await findEntry(worldbookUid); - if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } } - - const keep = 4, toEnd = Math.max(sc, messages.length - keep); - if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); - const toSum = messages.slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); - const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); - const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); - const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); - const newSc = toEnd; - - if (e) { - const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) c = c.substring(0, s).trimEnd() + c.substring(ed + 17); - const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; - existSum[String(nextK)] = sum; - const rem = messages.slice(toEnd).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); - const arr = [`SMS_summary:${JSON.stringify(existSum)}`, ...rem]; - c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; - en.content = c.trim(); await saveWorldInfo(bookName, worldData); - } - reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: newSc }); - } catch (e) { replyErr('COMPRESS_SMS_RESULT', requestId, `压缩失败: ${e.message}`); } -} - -async function handleCheckStrangerWb({ requestId, strangerName }) { - const r = await searchEntry(strangerName); - postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) }); -} - -async function handleGenNpc({ requestId, strangerName, strangerInfo }) { - try { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡'); - const primary = char.data?.extensions?.world; - if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书'); - const comm = getCommSettings(); - const msgs = buildNpcGenerationMessages({ strangerName, strangerInfo: strangerInfo || '(无描述)', storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50 }); - const npc = await callLLMJson({ messages: msgs, validate: V.npc }); - if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据'); - const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`); - const { createWorldInfoEntry } = await import("../../../../../world-info.js"); - const newE = createWorldInfoEntry(primary, wd); if (!newE) return replyErr('GENERATE_NPC_RESULT', requestId, '创建世界书条目失败'); - Object.assign(newE, { key: npc.aliases || [npc.name], comment: npc.name, content: formatNpcToWorldbookContent(npc), constant: false, selective: true, disable: false, position: typeof comm.npcPosition === 'number' ? comm.npcPosition : 0, order: typeof comm.npcOrder === 'number' ? comm.npcOrder : 100 }); - await saveWorldInfo(primary, wd, true); - reply('GENERATE_NPC_RESULT', requestId, { success: true, npcData: npc, worldbookUid: String(newE.uid), worldbook: primary }); - } catch (e) { replyErr('GENERATE_NPC_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) { - try { - const comm = getCommSettings(); - const msgs = buildExtractStrangersMessages({ storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] }); - const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr }); - if (!Array.isArray(data)) return replyErr('EXTRACT_STRANGERS_RESULT', requestId, '提取失败:无法解析 JSON 数据'); - const strangers = data.filter(s => s?.name).map(s => ({ name: s.name, avatar: s.name[0] || '?', color: '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: s.location || '未知', info: s.info || '' })); - reply('EXTRACT_STRANGERS_RESULT', requestId, { success: true, strangers }); - } catch (e) { replyErr('EXTRACT_STRANGERS_RESULT', requestId, `提取失败: ${e.message}`); } -} - -async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) { - try { - const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildSceneSwitchMessages({ prevLocationName: prevLocationName || '未知地点', prevLocationInfo: prevLocationInfo || '', targetLocationName: targetLocationName || '未知地点', targetLocationType: targetLocationType || 'sub', targetLocationInfo: targetLocationInfo || '', storyOutline: formatOutlinePrompt(), stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, playerAction: playerAction || '', mode }); - const data = await callLLMJson({ messages: msgs, validate: V.scene }); - if (!data || !V.scene(data)) return replyErr('SCENE_SWITCH_RESULT', requestId, '场景生成失败:无法解析 JSON 数据'); - const delta = data.review?.deviation?.score_delta || 0, old = store?.deviationScore || 0, newS = Math.min(100, Math.max(0, old + delta)); - if (store) { store.deviationScore = newS; if (targetLocationType !== 'home') store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } - const lm = data.local_map || data.scene_setup?.local_map || null; - reply('SCENE_SWITCH_RESULT', requestId, { success: true, sceneData: { review: data.review, localMap: lm, strangers: [], scoreDelta: delta, newScore: newS } }); - checkAutoSim(requestId); - } catch (e) { replyErr('SCENE_SWITCH_RESULT', requestId, `场景切换失败: ${e.message}`); } -} - -async function handleExecSlash({ command }) { - try { - if (typeof command !== 'string') return; - for (const line of command.split(/\r?\n/).map(l => l.trim()).filter(Boolean)) { - if (/^\/(send|sendas|as)\b/i.test(line)) await processCommands(line); - } - } catch (e) { console.warn('[Story Outline] Slash command failed:', e); } -} - -async function handleSendInvite({ requestId, contactName, contactUid, targetLocation, smsHistory }) { - try { - const comm = getCommSettings(); - let charC = ''; - if (contactUid) { const es = Object.values(world_info?.entries || world_info || {}); charC = es.find(e => e.uid?.toString() === contactUid.toString())?.content || ''; } - const msgs = buildInviteMessages({ contactName, userName: name1 || '{{user}}', targetLocation, storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, smsHistoryContent: buildSmsHistoryContent(smsHistory || ''), characterContent: charC }); - const data = await callLLMJson({ messages: msgs, validate: V.inv }); - if (typeof data?.invite !== 'boolean') return replyErr('SEND_INVITE_RESULT', requestId, '邀请处理失败:无法解析 JSON 数据'); - reply('SEND_INVITE_RESULT', requestId, { success: true, inviteData: { accepted: data.invite, reply: data.reply, targetLocation } }); - } catch (e) { replyErr('SEND_INVITE_RESULT', requestId, `邀请处理失败: ${e.message}`); } -} - -async function handleGenLocalMap({ requestId, outdoorDescription }) { - try { - const msgs = buildLocalMapGenMessages({ storyOutline: formatOutlinePrompt(), outdoorDescription: outdoorDescription || '', historyCount: getCommSettings().historyCount || 50 }); - const data = await callLLMJson({ messages: msgs, validate: V.lm }); - if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据'); - reply('GENERATE_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); - } catch (e) { replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, `局部地图生成失败: ${e.message}`); } -} - -async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) { - try { - const store = getOutlineStore(), comm = getCommSettings(); - const msgs = buildLocalMapRefreshMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: currentLocalMap?.description || '', currentLocalMap: currentLocalMap || null, outdoorDescription: outdoorDescription || '', historyCount: comm.historyCount || 50, playerLocation: store?.playerLocation }); - const data = await callLLMJson({ messages: msgs, validate: V.lm }); - if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据'); - reply('REFRESH_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); - } catch (e) { replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, `局部地图刷新失败: ${e.message}`); } -} - -async function handleGenLocalScene({ requestId, locationName, locationInfo }) { - try { - const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildLocalSceneGenMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: locationInfo || '', stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, mode, playerLocation: store?.playerLocation }); - const data = await callLLMJson({ messages: msgs, validate: V.lscene }); - if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据'); - if (store) { store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } - const ssf = data.side_story || null, intro = ssf?.Introduce || ssf?.introduce || ''; - const ss = ssf ? (() => { const { Introduce, introduce: i2, story, ...rest } = ssf; return rest; })() : null; - reply('GENERATE_LOCAL_SCENE_RESULT', requestId, { success: true, sceneSetup: { sideStory: ss, review: data.review || null }, introduce: intro, loc: locationName }); - checkAutoSim(requestId); - } catch (e) { replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, `局部剧情生成失败: ${e.message}`); } -} - -async function handleGenWorld({ requestId, playerRequests }) { - try { - const comm = getCommSettings(), mode = getGlobalSettings().mode || 'story', store = getOutlineStore(); - - // 递归查找函数 - 在任意层级找到目标键 - const deepFind = (obj, key) => { - if (!obj || typeof obj !== 'object') return null; - if (obj[key] !== undefined) return obj[key]; - for (const v of Object.values(obj)) { - const found = deepFind(v, key); - if (found !== null) return found; - } - return null; - }; - - const normalizeStep1Data = (data) => { - if (!data || typeof data !== 'object') return null; - - // 构建标准化结构,从任意位置提取数据 - const result = { meta: {} }; - - // 提取 truth(可能在 meta.truth, data.truth, 或者 data 本身就是 truth) - result.meta.truth = deepFind(data, 'truth') - || (data.background && data.driver ? data : null) - || { background: deepFind(data, 'background'), driver: deepFind(data, 'driver') }; - - // 提取 onion_layers - result.meta.onion_layers = deepFind(data, 'onion_layers') || {}; - - // 统一洋葱层级为数组格式 - ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(k => { - const v = result.meta.onion_layers[k]; - if (v && !Array.isArray(v) && typeof v === 'object') { - result.meta.onion_layers[k] = [v]; - } - }); - - // 提取 atmosphere - result.meta.atmosphere = deepFind(data, 'atmosphere') || { reasoning: '', current: { environmental: '', npc_attitudes: '' } }; - - // 提取 trajectory - result.meta.trajectory = deepFind(data, 'trajectory') || { reasoning: '', ending: '' }; - - // 提取 user_guide - result.meta.user_guide = deepFind(data, 'user_guide') || { current_state: '', guides: [] }; - - return result; - }; - - // 辅助模式 - if (mode === 'assist') { - const msgs = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, mode: 'assist' }); - const wd = await callLLMJson({ messages: msgs, validate: V.wga }); - if (!wd?.maps?.outdoor || !Array.isArray(wd.maps.outdoor.nodes)) return replyErr('GENERATE_WORLD_RESULT', requestId, '生成失败:返回数据缺少地图节点'); - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); } - return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd }); - } - - // Step 1 - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' }); - const s1m = buildWorldGenStep1Messages({ historyCount: comm.historyCount || 50, playerRequests }); - const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 })); - - // 简化验证 - 只要有基本数据就行 - if (!s1d?.meta) { - return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 1 失败:无法解析大纲数据,请重试'); - } - step1Cache = { step1Data: s1d, playerRequests: playerRequests || '' }; - - // Step 2 - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: 'Step 1 完成,1 秒后开始构建世界细节 (Step 2/2)...' }); - await new Promise(r => setTimeout(r, 1000)); - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' }); - - const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, step1Data: s1d }); - const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); - if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } - if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); - - const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; - step1Cache = null; - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } - reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); - } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleRetryStep2({ requestId }) { - try { - if (!step1Cache?.step1Data?.meta) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 重试失败:缺少 Step 1 数据,请重新开始生成'); - const comm = getCommSettings(), store = getOutlineStore(), s1d = step1Cache.step1Data, pr = step1Cache.playerRequests || ''; - - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '1 秒后重试构建世界细节 (Step 2/2)...' }); - await new Promise(r => setTimeout(r, 1000)); - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' }); - - const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests: pr, step1Data: s1d }); - const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); - if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } - if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); - - const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; - step1Cache = null; - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } - reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); - } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `Step 2 重试失败: ${e.message}`); } -} - -async function handleSimWorld({ requestId, currentData, isAuto }) { - try { - const store = getOutlineStore(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildWorldSimMessages({ mode, currentWorldData: currentData || '{}', historyCount: getCommSettings().historyCount || 50, deviationScore: store?.deviationScore || 0 }); - const data = await callLLMJson({ messages: msgs, validate: V.w }); - if (!data || !V.w(data)) return replyErr('SIMULATE_WORLD_RESULT', requestId, mode === 'assist' ? '世界推演失败:无法解析 JSON 数据(需包含 world 或 maps 字段)' : '世界推演失败:无法解析 JSON 数据'); - const orig = safe(() => JSON.parse(currentData)) || {}, merged = mergeSimData(orig, data); - if (store) { store.stage = (store.stage || 0) + 1; store.simulationProgress = 0; store.simulationTarget = randRange(3, 7); saveMetadataDebounced?.(); } - reply('SIMULATE_WORLD_RESULT', requestId, { success: true, simData: merged, isAuto: !!isAuto }); - } catch (e) { replyErr('SIMULATE_WORLD_RESULT', requestId, `推演失败: ${e.message}`); } -} - -function handleSaveSettings(d) { - if (d.globalSettings) saveGlobalSettings(d.globalSettings); - if (d.commSettings) saveCommSettings(d.commSettings); - const store = getOutlineStore(); - if (store) { - ['stage', 'deviationScore', 'simulationProgress', 'simulationTarget', 'playerLocation'].forEach(k => { if (d[k] !== undefined) store[k] = d[k]; }); - if (d.dataChecked) store.dataChecked = d.dataChecked; - if (d.allData) store.outlineData = d.allData; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - } - injectOutline(); -} - -function handleSavePrompts(d) { - if (!d?.promptConfig) return; - setPromptConfig?.(d.promptConfig, true); - postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() }); -} - -function handleSaveContacts(d) { - const store = getOutlineStore(); if (!store) return; - store.outlineData ||= {}; - if (d.contacts) store.outlineData.contacts = d.contacts; - if (d.strangers) store.outlineData.strangers = d.strangers; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - injectOutline(); -} - -function handleSaveAllData(d) { - const store = getOutlineStore(); - if (store && d.allData) { - store.outlineData = d.allData; - if (d.playerLocation !== undefined) store.playerLocation = d.playerLocation; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - injectOutline(); - } -} - -function handleSaveCharSmsHistory(d) { - const h = getCharSmsHistory(); - if (!h) return; - const sums = d?.summaries ?? d?.history?.summaries; - if (!sums || typeof sums !== 'object' || Array.isArray(sums)) return; - h.summaries = sums; - saveMetadataDebounced?.(); - injectOutline(); -} - -// 处理器映射 -const handlers = { - FRAME_READY: () => { frameReady = true; flushPending(); loadAndSend(); }, - CLOSE_PANEL: hideOverlay, - SAVE_MAP_DATA: d => { const s = getOutlineStore(); if (s && d.mapData) { s.mapData = d.mapData; s.updatedAt = Date.now(); saveMetadataDebounced?.(); } }, - GET_SETTINGS: sendSettings, - SAVE_SETTINGS: handleSaveSettings, - SAVE_PROMPTS: handleSavePrompts, - SAVE_CONTACTS: handleSaveContacts, - SAVE_ALL_DATA: handleSaveAllData, - FETCH_MODELS: handleFetchModels, - TEST_CONNECTION: handleTestConn, - CHECK_WORLDBOOK_UID: handleCheckUid, - SEND_SMS: handleSendSms, - LOAD_SMS_HISTORY: handleLoadSmsHistory, - SAVE_SMS_HISTORY: handleSaveSmsHistory, - SAVE_CHAR_SMS_HISTORY: handleSaveCharSmsHistory, - COMPRESS_SMS: handleCompressSms, - CHECK_STRANGER_WORLDBOOK: handleCheckStrangerWb, - GENERATE_NPC: handleGenNpc, - EXTRACT_STRANGERS: handleExtractStrangers, - SCENE_SWITCH: handleSceneSwitch, - EXECUTE_SLASH_COMMAND: handleExecSlash, - SEND_INVITE: handleSendInvite, - GENERATE_WORLD: handleGenWorld, - RETRY_WORLD_GEN_STEP2: handleRetryStep2, - SIMULATE_WORLD: handleSimWorld, - GENERATE_LOCAL_MAP: handleGenLocalMap, - REFRESH_LOCAL_MAP: handleRefreshLocalMap, - GENERATE_LOCAL_SCENE: handleGenLocalScene -}; - -const handleMsg = ({ data }) => { if (data?.source === "LittleWhiteBox-OutlineFrame") handlers[data.type]?.(data); }; - -// ==================== 10. UI管理 ==================== - -/** 指针拖拽 */ -function setupDrag(el, { onStart, onMove, onEnd, shouldHandle }) { - if (!el) return; - let state = null; - el.addEventListener('pointerdown', e => { if (shouldHandle && !shouldHandle()) return; e.preventDefault(); e.stopPropagation(); state = onStart(e); state.pointerId = e.pointerId; el.setPointerCapture(e.pointerId); }); - el.addEventListener('pointermove', e => state && onMove(e, state)); - const end = () => { if (!state) return; onEnd?.(state); try { el.releasePointerCapture(state.pointerId); } catch { } state = null; }; - ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(ev => el.addEventListener(ev, end)); -} - -/** 创建Overlay */ -function createOverlay() { - if (overlayCreated) return; - overlayCreated = true; - document.body.appendChild($(buildOverlayHtml(IFRAME_PATH))[0]); - const overlay = document.getElementById("xiaobaix-story-outline-overlay"), wrap = overlay.querySelector(".xb-so-frame-wrap"), iframe = overlay.querySelector("iframe"); - const setPtr = v => iframe && (iframe.style.pointerEvents = v); - - // 拖拽 - setupDrag(overlay.querySelector(".xb-so-drag-handle"), { - shouldHandle: () => !isMobile(), - onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sl: parseFloat(wrap.style.left), st: parseFloat(wrap.style.top) }; }, - onMove(e, s) { wrap.style.left = Math.max(0, Math.min(overlay.clientWidth - wrap.offsetWidth, s.sl + e.clientX - s.sx)) + 'px'; wrap.style.top = Math.max(0, Math.min(overlay.clientHeight - wrap.offsetHeight, s.st + e.clientY - s.sy)) + 'px'; }, - onEnd: () => setPtr('') - }); - - // 缩放 - setupDrag(overlay.querySelector(".xb-so-resize-handle"), { - shouldHandle: () => !isMobile(), - onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sw: wrap.offsetWidth, sh: wrap.offsetHeight, ratio: wrap.offsetWidth / wrap.offsetHeight }; }, - onMove(e, s) { const dx = e.clientX - s.sx, dy = e.clientY - s.sy, delta = Math.abs(dx) > Math.abs(dy) ? dx : dy * s.ratio; let w = Math.max(400, Math.min(window.innerWidth * 0.95, s.sw + delta)), h = w / s.ratio; if (h > window.innerHeight * 0.9) { h = window.innerHeight * 0.9; w = h * s.ratio; } if (h < 300) { h = 300; w = h * s.ratio; } wrap.style.width = w + 'px'; wrap.style.height = h + 'px'; }, - onEnd: () => setPtr('') - }); - - // 移动端 - setupDrag(overlay.querySelector(".xb-so-resize-mobile"), { - shouldHandle: () => isMobile(), - onStart(e) { setPtr('none'); return { sy: e.clientY, sh: wrap.offsetHeight }; }, - onMove(e, s) { wrap.style.height = Math.max(44, Math.min(window.innerHeight * 0.9, s.sh + e.clientY - s.sy)) + 'px'; }, - onEnd: () => setPtr('') - }); - - window.addEventListener("message", handleMsg); -} - -function updateLayout() { - const wrap = document.querySelector(".xb-so-frame-wrap"); if (!wrap) return; - const drag = document.querySelector(".xb-so-drag-handle"), resize = document.querySelector(".xb-so-resize-handle"), mobile = document.querySelector(".xb-so-resize-mobile"); - if (isMobile()) { if (drag) drag.style.display = 'none'; if (resize) resize.style.display = 'none'; if (mobile) mobile.style.display = 'flex'; wrap.style.cssText = MOBILE_LAYOUT_STYLE; const fixedHeight = window.innerHeight * 0.4; wrap.style.height = Math.max(44, fixedHeight) + 'px'; wrap.style.top = '0px'; } - else { if (drag) drag.style.display = 'block'; if (resize) resize.style.display = 'block'; if (mobile) mobile.style.display = 'none'; wrap.style.cssText = DESKTOP_LAYOUT_STYLE; } -} - -function showOverlay() { if (!overlayCreated) createOverlay(); frameReady = false; const f = document.getElementById("xiaobaix-story-outline-iframe"); if (f) f.src = IFRAME_PATH; updateLayout(); $("#xiaobaix-story-outline-overlay").show(); } -function hideOverlay() { $("#xiaobaix-story-outline-overlay").hide(); } - -let lastIsMobile = isMobile(); -window.addEventListener('resize', () => { const nowIsMobile = isMobile(); if (nowIsMobile !== lastIsMobile) { lastIsMobile = nowIsMobile; updateLayout(); } }); - - -// ==================== 11. 事件与初始化 ==================== - -let eventsRegistered = false; - -function addBtnToMsg(mesId) { - if (!getSettings().storyOutline?.enabled) return; - const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`); - if (!msg || msg.querySelector('.xiaobaix-story-outline-btn')) return; - const btn = document.createElement('div'); - btn.className = 'mes_btn xiaobaix-story-outline-btn'; - btn.title = '小白板'; - btn.dataset.mesid = mesId; - btn.innerHTML = ''; - btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (!getSettings().storyOutline?.enabled) return; currentMesId = Number(mesId); showOverlay(); loadAndSend(); }); - if (window.registerButtonToSubContainer?.(mesId, btn)) return; - msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn); -} - -function initBtns() { - if (!getSettings().storyOutline?.enabled) return; - $("#chat .mes").each((_, el) => { const id = el.getAttribute("mesid"); if (id != null) addBtnToMsg(id); }); -} - -function registerEvents() { - if (eventsRegistered) return; - eventsRegistered = true; - - initBtns(); - - events.on(event_types.CHAT_CHANGED, () => { setTimeout(initBtns, 80); setTimeout(injectOutline, 100); }); - events.on(event_types.GENERATION_STARTED, injectOutline); - - const handler = d => setTimeout(() => { - const id = d?.element ? $(d.element).attr("mesid") : d?.messageId; - id == null ? initBtns() : addBtnToMsg(id); - }, 50); - - events.onMany([ - event_types.USER_MESSAGE_RENDERED, - event_types.CHARACTER_MESSAGE_RENDERED, - event_types.MESSAGE_RECEIVED, - event_types.MESSAGE_UPDATED, - event_types.MESSAGE_SWIPED, - event_types.MESSAGE_EDITED - ], handler); - - setupSTEvents(); -} - -function cleanup() { - events.cleanup(); - eventsRegistered = false; - $(".xiaobaix-story-outline-btn").remove(); - hideOverlay(); - overlayCreated = false; frameReady = false; pendingMsgs = []; - window.removeEventListener("message", handleMsg); - document.getElementById("xiaobaix-story-outline-overlay")?.remove(); - removePrompt(); - if (presetCleanup) { presetCleanup(); presetCleanup = null; } -} - -// ==================== Toggle 监听(始终注册)==================== - -$(document).on("xiaobaix:storyOutline:toggle", (_e, enabled) => { - if (enabled) { - registerEvents(); - initBtns(); - injectOutline(); - } else { - cleanup(); - } -}); - -document.addEventListener('xiaobaixEnabledChanged', e => { - if (!e?.detail?.enabled) { - cleanup(); - } else if (getSettings().storyOutline?.enabled) { - registerEvents(); - initBtns(); - injectOutline(); - } -}); - -// ==================== 初始化 ==================== - -jQuery(() => { - if (!getSettings().storyOutline?.enabled) return; - registerEvents(); - setTimeout(injectOutline, 200); - window.registerModuleCleanup?.('storyOutline', cleanup); -}); - -export { cleanup }; +/** + * ============================================================================ + * Story Outline 模块 - 小白板 + * ============================================================================ + */ + +// ==================== 1. 导入与常量 ==================== + +import { extension_settings, saveMetadataDebounced } from "../../../../../extensions.js"; +import { chat_metadata, name1, processCommands, eventSource, event_types as st_event_types } from "../../../../../../script.js"; +import { loadWorldInfo, saveWorldInfo, world_names, world_info } from "../../../../../world-info.js"; +import { getContext } from "../../../../../st-context.js"; +import { streamingGeneration } from "../streaming-generation.js"; +import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; +import { createModuleEvents, event_types } from "../../core/event-manager.js"; +import { promptManager } from "../../../../../openai.js"; +import { + buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent, + buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages, + buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages, + buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages, + buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig +} from "./story-outline-prompt.js"; + +const events = createModuleEvents('storyOutline'); +const IFRAME_PATH = `${extensionFolderPath}/modules/story-outline/story-outline.html`; +const STORAGE_KEYS = { global: 'LittleWhiteBox_StoryOutline_GlobalSettings', comm: 'LittleWhiteBox_StoryOutline_CommSettings' }; +const SIZE_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_Size'; +const STORY_OUTLINE_ID = 'lwb_story_outline'; +const CHAR_CARD_UID = '__CHARACTER_CARD__'; +const DEBUG_KEY = 'LittleWhiteBox_StoryOutline_Debug'; + +let overlayCreated = false, frameReady = false, currentMesId = null, pendingMsgs = [], presetCleanup = null, step1Cache = null; +let iframeLoaded = false; + +// ==================== 2. 通用工具 ==================== + +const isMobile = () => window.innerWidth < 550; +const safe = fn => { try { return fn(); } catch { return null; } }; +const isDebug = () => { try { return localStorage.getItem(DEBUG_KEY) === '1'; } catch { return false; } }; +const getStore = (k, def) => safe(() => JSON.parse(localStorage.getItem(k))) || def; +const setStore = (k, v) => safe(() => localStorage.setItem(k, JSON.stringify(v))); +const randRange = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a; + +const getStoredSize = (isMob) => { + try { + const data = JSON.parse(localStorage.getItem(SIZE_STORAGE_KEY) || '{}'); + return isMob ? data.mobile : data.desktop; + } catch { return null; } +}; + +const setStoredSize = (isMob, size) => { + try { + if (!size) return; + const data = JSON.parse(localStorage.getItem(SIZE_STORAGE_KEY) || '{}'); + if (isMob) { + if (Number.isFinite(size.height) && size.height > 44) { + data.mobile = { height: size.height }; + } + } else { + data.desktop = {}; + if (Number.isFinite(size.width) && size.width > 300) data.desktop.width = size.width; + if (Number.isFinite(size.height) && size.height > 200) data.desktop.height = size.height; + } + localStorage.setItem(SIZE_STORAGE_KEY, JSON.stringify(data)); + } catch {} +}; + +// ==================== 3. JSON解析 ==================== + +function fixJson(s) { + if (!s || typeof s !== 'string') return s; + let r = s.trim() + .replace(/[""]/g, '"').replace(/['']/g, "'") + .replace(/"([^"']+)'[\s]*:/g, '"$1":') + .replace(/'([^"']+)"[\s]*:/g, '"$1":') + .replace(/:[\s]*'([^']*)'[\s]*([,}\]])/g, ':"$1"$2') + .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":') + .replace(/,[\s\n]*([}\]])/g, '$1') + .replace(/:\s*undefined\b/g, ': null').replace(/:\s*NaN\b/g, ': null'); + let braces = 0, brackets = 0, inStr = false, esc = false; + for (const c of r) { + if (esc) { esc = false; continue; } + if (c === '\\' && inStr) { esc = true; continue; } + if (c === '"') { inStr = !inStr; continue; } + if (!inStr) { + if (c === '{') braces++; else if (c === '}') braces--; + if (c === '[') brackets++; else if (c === ']') brackets--; + } + } + while (braces-- > 0) r += '}'; + while (brackets-- > 0) r += ']'; + return r; +} + +function extractJson(input, isArray = false) { + if (!input) return null; + if (typeof input === 'object' && input !== null) { + if (isArray && Array.isArray(input)) return input; + if (!isArray && !Array.isArray(input)) { + const content = input.choices?.[0]?.message?.content + ?? input.choices?.[0]?.message?.reasoning_content + ?? input.content ?? input.reasoning_content; + if (content != null) return extractJson(String(content).trim(), isArray); + if (!input.choices) return input; + } + return null; + } + const str = String(input).trim() + .replace(/^\uFEFF/, '') + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + .replace(/\r\n?/g, '\n'); + if (!str) return null; + const tryParse = s => { try { return JSON.parse(s); } catch { return null; } }; + const ok = (o, arr) => o != null && (arr ? Array.isArray(o) : typeof o === 'object' && !Array.isArray(o)); + const score = o => (o?.meta ? 10 : 0) + (o?.world ? 5 : 0) + (o?.maps ? 5 : 0) + + (o?.truth ? 3 : 0) + (o?.onion_layers ? 3 : 0) + (o?.atmosphere ? 3 : 0) + (o?.trajectory ? 3 : 0) + (o?.user_guide ? 3 : 0); + let r = tryParse(str); + if (ok(r, isArray) && score(r) > 0) return r; + const open = isArray ? '[' : '{'; + const candidates = []; + for (let i = 0; i < str.length; i++) { + if (str[i] !== open) continue; + let depth = 0, inStr = false, esc = false; + for (let j = i; j < str.length; j++) { + const c = str[j]; + if (esc) { esc = false; continue; } + if (c === '\\' && inStr) { esc = true; continue; } + if (c === '"') { inStr = !inStr; continue; } + if (inStr) continue; + if (c === '{' || c === '[') depth++; + else if (c === '}' || c === ']') depth--; + if (depth === 0) { + candidates.push({ start: i, end: j, text: str.slice(i, j + 1) }); + i = j; + break; + } + } + } + candidates.sort((a, b) => b.text.length - a.text.length); + let best = null, bestScore = -1; + for (const { text } of candidates) { + r = tryParse(text); + if (ok(r, isArray)) { + const s = score(r); + if (s > bestScore) { best = r; bestScore = s; } + if (s >= 10) return r; + continue; + } + const fixed = fixJson(text); + r = tryParse(fixed); + if (ok(r, isArray)) { + const s = score(r); + if (s > bestScore) { best = r; bestScore = s; } + if (s >= 10) return r; + } + } + if (best) return best; + const firstBrace = str.indexOf('{'); + const lastBrace = str.lastIndexOf('}'); + if (!isArray && firstBrace !== -1 && lastBrace > firstBrace) { + const chunk = str.slice(firstBrace, lastBrace + 1); + r = tryParse(chunk) || tryParse(fixJson(chunk)); + if (ok(r, isArray)) return r; + } + return null; +} + +export { extractJson }; + +// ==================== 4. 存储管理 ==================== + +const getSettings = () => { const e = extension_settings[EXT_ID] ||= {}; e.storyOutline ||= { enabled: true }; return e; }; + +function getOutlineStore() { + if (!chat_metadata) return null; + const ext = chat_metadata.extensions ||= {}, lwb = ext[EXT_ID] ||= {}; + return lwb.storyOutline ||= { + mapData: null, stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: 5, playerLocation: '家', + outlineData: { meta: null, world: null, outdoor: null, indoor: null, sceneSetup: null, strangers: null, contacts: null }, + dataChecked: { meta: true, world: true, outdoor: true, indoor: true, sceneSetup: true, strangers: false, contacts: false, characterContactSms: false } + }; +} + +const getGlobalSettings = () => getStore(STORAGE_KEYS.global, { apiUrl: '', apiKey: '', model: '', mode: 'assist' }); +const saveGlobalSettings = s => setStore(STORAGE_KEYS.global, s); +const getCommSettings = () => ({ historyCount: 50, npcPosition: 0, npcOrder: 100, ...getStore(STORAGE_KEYS.comm, {}) }); +const saveCommSettings = s => setStore(STORAGE_KEYS.comm, s); + +function getCharInfo() { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + return { + name: char?.name || char?.data?.name || char?.avatar || '角色卡', + desc: String((char?.description ?? char?.data?.description ?? '') || '').trim() || '{{description}}' + }; +} + +function getCharSmsHistory() { + if (!chat_metadata) return null; + const root = chat_metadata.LittleWhiteBox_StoryOutline ||= {}; + const h = root.characterContactSmsHistory ||= { messages: [], summarizedCount: 0, summaries: {} }; + h.messages ||= []; h.summarizedCount ||= 0; h.summaries ||= {}; + return h; +} + +// ==================== 5. LLM调用 ==================== + +async function callLLM(promptOrMsgs, useRaw = false) { + const { apiUrl, apiKey, model } = getGlobalSettings(); + const normalize = r => { + if (r == null) return ''; + if (typeof r === 'string') return r; + if (typeof r === 'object') { + if (r.data && typeof r.data === 'object') return normalize(r.data); + if (typeof r.text === 'string') return r.text; + if (typeof r.response === 'string') return r.response; + const inner = r.content?.trim?.() || r.reasoning_content?.trim?.() || r.choices?.[0]?.message?.content?.trim?.() || r.choices?.[0]?.message?.reasoning_content?.trim?.() || null; + if (inner != null) return String(inner); + return safe(() => JSON.stringify(r)) || String(r); + } + return String(r); + }; + const opts = { nonstream: 'true', lock: 'on' }; + if (apiUrl?.trim()) Object.assign(opts, { api: 'openai', apiurl: apiUrl.trim(), ...(apiKey && { apipassword: apiKey }), ...(model && { model }) }); + if (useRaw) { + const messages = Array.isArray(promptOrMsgs) + ? promptOrMsgs + : [{ role: 'user', content: String(promptOrMsgs || '').trim() }]; + const roleMap = { user: 'user', assistant: 'assistant', system: 'sys' }; + const topParts = messages + .filter(m => m?.role && typeof m.content === 'string' && m.content.trim()) + .map(m => { + const role = roleMap[m.role] || m.role; + return `${role}={${m.content}}`; + }); + const topParam = topParts.join(';'); + opts.top = topParam; + const raw = await streamingGeneration.xbgenrawCommand(opts, ''); + const text = normalize(raw).trim(); + if (isDebug()) { + try { + console.groupCollapsed('[StoryOutline] callLLM(useRaw via xbgenrawCommand)'); + console.log('opts.top.length', topParam.length); + console.log('raw', raw); + console.log('normalized.length', text.length); + console.groupEnd(); + } catch { } + } + return text; + } + opts.as = 'user'; + opts.position = 'history'; + return normalize(await streamingGeneration.xbgenCommand(opts, promptOrMsgs)).trim(); +} + +async function callLLMJson({ messages, useRaw = true, isArray = false, validate }) { + try { + const result = await callLLM(messages, useRaw); + if (isDebug()) { + try { + const s = String(result ?? ''); + console.groupCollapsed('[StoryOutline] callLLMJson'); + console.log({ useRaw, isArray, length: s.length }); + console.log('result.head', s.slice(0, 500)); + console.log('result.tail', s.slice(Math.max(0, s.length - 500))); + console.groupEnd(); + } catch { } + } + const parsed = extractJson(result, isArray); + if (isDebug()) { + try { + console.groupCollapsed('[StoryOutline] extractJson'); + console.log('parsed', parsed); + console.log('validate', !!(parsed && validate?.(parsed))); + console.groupEnd(); + } catch { } + } + if (parsed && validate(parsed)) return parsed; + } catch { } + return null; +} + +// ==================== 6. 世界书操作 ==================== + +async function getCharWorldbooks() { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + if (!char) return []; + const books = [], primary = char.data?.extensions?.world; + if (primary && world_names?.includes(primary)) books.push(primary); + (world_info?.charLore || []).find(e => e.name === char.avatar)?.extraBooks?.forEach(b => { + if (world_names?.includes(b) && !books.includes(b)) books.push(b); + }); + return books; +} + +async function findEntry(uid) { + const uidNum = parseInt(uid, 10); + if (isNaN(uidNum)) return null; + for (const book of await getCharWorldbooks()) { + const data = await loadWorldInfo(book); + if (data?.entries?.[uidNum]) return { bookName: book, entry: data.entries[uidNum], uidNumber: uidNum, worldData: data }; + } + return null; +} + +async function searchEntry(name) { + const nl = (name || '').toLowerCase().trim(); + for (const book of await getCharWorldbooks()) { + const data = await loadWorldInfo(book); + if (!data?.entries) continue; + for (const [uid, entry] of Object.entries(data.entries)) { + const keys = Array.isArray(entry.key) ? entry.key : []; + if (keys.some(k => { const kl = (k || '').toLowerCase().trim(); return kl === nl || kl.includes(nl) || nl.includes(kl); })) + return { uid: String(uid), bookName: book, entry }; + } + } + return null; +} + +// ==================== 7. 剧情注入 ==================== + +const getVisibleLayers = stage => ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].slice(0, Math.min(Math.max(0, stage), 3) + 2); + +function formatOutlinePrompt() { + const store = getOutlineStore(); + if (!store?.outlineData) return ""; + const { outlineData: d, dataChecked: c, playerLocation } = store, stage = store.stage ?? 0; + let text = "## Story Outline (剧情数据)\n\n", has = false; + if (c?.meta && d.meta?.truth) { + has = true; + text += "### 世界真相 (World Truth)\n> 注意:以下信息仅供生成逻辑参考,不可告知玩家。\n"; + if (d.meta.truth.background) text += `* 背景真相: ${d.meta.truth.background}\n`; + const dr = d.meta.truth.driver; + if (dr) { if (dr.source) text += `* 驱动: ${dr.source}\n`; if (dr.target_end) text += `* 目的: ${dr.target_end}\n`; if (dr.tactic) text += `* 当前手段: ${dr.tactic}\n`; } + const atm = d.meta.atmosphere?.current; + if (atm) { + if (atm.environmental) text += `* 当前气氛: ${atm.environmental}\n`; + if (atm.npc_attitudes) text += `* NPC态度: ${atm.npc_attitudes}\n`; + } + const onion = d.meta.onion_layers || d.meta.truth.onion_layers; + if (onion) { + text += "* 当前可见层级:\n"; + getVisibleLayers(stage).forEach(k => { + const l = onion[k]; if (!l || !Array.isArray(l) || !l.length) return; + const name = k.replace(/_/g, ' - '); + l.forEach(i => { text += ` - [${name}] ${i.desc}: ${i.logic}\n`; }); + }); + } + text += "\n"; + } + if (c?.world && d.world?.news?.length) { has = true; text += "### 世界资讯 (News)\n"; d.world.news.forEach(n => { text += `* ${n.title}: ${n.content}\n`; }); text += "\n"; } + let mapC = "", locNode = null; + if (c?.outdoor && d.outdoor) { + if (d.outdoor.description) mapC += `> 大地图环境: ${d.outdoor.description}\n`; + if (playerLocation && d.outdoor.nodes?.length) locNode = d.outdoor.nodes.find(n => n.name === playerLocation); + } + if (!locNode && c?.indoor && d.indoor?.nodes?.length && playerLocation) locNode = d.indoor.nodes.find(n => n.name === playerLocation); + const indoorMap = (c?.indoor && playerLocation && d.indoor && typeof d.indoor === 'object' && !Array.isArray(d.indoor)) ? d.indoor[playerLocation] : null; + const locText = indoorMap?.description || locNode?.info || ''; + if (playerLocation && locText) mapC += `\n> 当前地点 (${playerLocation}):\n${locText}\n`; + if (c?.indoor && d.indoor && !locNode && !indoorMap && d.indoor.description) { mapC += d.indoor.name ? `\n> 当前地点: ${d.indoor.name}\n` : "\n> 局部区域:\n"; mapC += `${d.indoor.description}\n`; } + if (mapC) { has = true; text += `### 环境信息 (Environment)\n${mapC}\n`; } + let charC = ""; + if (c?.contacts && d.contacts?.length) { charC += "* 联络人:\n"; d.contacts.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } + if (c?.strangers && d.strangers?.length) { charC += "* 陌路人:\n"; d.strangers.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } + if (charC) { has = true; text += `### 周边人物 (Characters)\n${charC}\n`; } + if (c?.sceneSetup && d.sceneSetup) { + const ss = d.sceneSetup.sideStory || d.sceneSetup.side_story || d.sceneSetup; + if (ss && (ss.surface || ss.inner)) { has = true; text += "### 当前剧情 (Current Scene)\n"; if (ss.surface) text += `* 表象: ${ss.surface}\n`; if (ss.inner) text += `* 里层 (潜台词): ${ss.inner}\n`; text += "\n"; } + } + if (c?.characterContactSms) { + const { name: charName } = getCharInfo(), hist = getCharSmsHistory(); + const sums = hist?.summaries || {}, sumKeys = Object.keys(sums).filter(k => k !== '_count').sort((a, b) => a - b); + const msgs = hist?.messages || [], sc = hist?.summarizedCount || 0, rem = msgs.slice(sc); + if (sumKeys.length || rem.length) { + has = true; text += `### ${charName}短信记录\n`; + if (sumKeys.length) text += `[摘要] ${sumKeys.map(k => sums[k]).join(';')}\n`; + if (rem.length) text += rem.map(m => `${m.type === 'sent' ? '{{user}}' : charName}:${m.text}`).join('\n') + "\n"; + text += "\n"; + } + } + return has ? text.trim() : ""; +} + +function ensurePrompt() { + if (!promptManager) return false; + let prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + if (!prompt) { + promptManager.addPrompt({ identifier: STORY_OUTLINE_ID, name: '剧情地图', role: 'system', content: '', system_prompt: false, marker: false, extension: true }, STORY_OUTLINE_ID); + prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + } + const char = promptManager.activeCharacter; + if (!char) return true; + const order = promptManager.getPromptOrderForCharacter(char); + const exists = order.some(e => e.identifier === STORY_OUTLINE_ID); + if (!exists) { const idx = order.findIndex(e => e.identifier === 'charDescription'); order.splice(idx !== -1 ? idx : 0, 0, { identifier: STORY_OUTLINE_ID, enabled: true }); } + else { const entry = order.find(e => e.identifier === STORY_OUTLINE_ID); if (entry && !entry.enabled) entry.enabled = true; } + promptManager.render?.(false); + return true; +} + +function updatePromptContent() { + if (!promptManager) return; + if (!getSettings().storyOutline?.enabled) { removePrompt(); return; } + ensurePrompt(); + const store = getOutlineStore(), prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + if (!prompt) return; + const { dataChecked } = store || {}; + const hasAny = dataChecked && Object.values(dataChecked).some(v => v === true); + prompt.content = (!hasAny || !store) ? '' : (formatOutlinePrompt() || ''); + promptManager.render?.(false); +} + +function removePrompt() { + if (!promptManager) return; + const prompts = promptManager.serviceSettings?.prompts; + if (prompts) { const idx = prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (idx !== -1) prompts.splice(idx, 1); } + const orders = promptManager.serviceSettings?.prompt_order; + if (orders) orders.forEach(cfg => { if (cfg?.order) { const idx = cfg.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (idx !== -1) cfg.order.splice(idx, 1); } }); + promptManager.render?.(false); +} + +function setupSTEvents() { + if (presetCleanup) return; + const onChanged = () => { if (getSettings().storyOutline?.enabled) setTimeout(() => { ensurePrompt(); updatePromptContent(); }, 100); }; + const onExport = preset => { + if (!preset) return; + if (preset.prompts) { const i = preset.prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (i !== -1) preset.prompts.splice(i, 1); } + if (preset.prompt_order) preset.prompt_order.forEach(c => { if (c?.order) { const i = c.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (i !== -1) c.order.splice(i, 1); } }); + }; + eventSource.on(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); + eventSource.on(st_event_types.OAI_PRESET_EXPORT_READY, onExport); + presetCleanup = () => { try { eventSource.removeListener(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); } catch { } try { eventSource.removeListener(st_event_types.OAI_PRESET_EXPORT_READY, onExport); } catch { } }; +} + +const injectOutline = () => updatePromptContent(); + +// ==================== 8. iframe通讯 ==================== + +function postFrame(payload) { + const iframe = document.getElementById("xiaobaix-story-outline-iframe"); + if (!iframe?.contentWindow || !frameReady) { pendingMsgs.push(payload); return; } + iframe.contentWindow.postMessage({ source: "LittleWhiteBox", ...payload }, "*"); +} + +const flushPending = () => { if (!frameReady) return; const f = document.getElementById("xiaobaix-story-outline-iframe"); pendingMsgs.forEach(p => f?.contentWindow?.postMessage({ source: "LittleWhiteBox", ...p }, "*")); pendingMsgs = []; }; + +function sendSettings() { + const store = getOutlineStore(), { name: charName, desc: charDesc } = getCharInfo(); + postFrame({ + type: "LOAD_SETTINGS", globalSettings: getGlobalSettings(), commSettings: getCommSettings(), + stage: store?.stage ?? 0, deviationScore: store?.deviationScore ?? 0, simulationProgress: store?.simulationProgress ?? 0, + simulationTarget: store?.simulationTarget ?? 5, playerLocation: store?.playerLocation ?? '家', + dataChecked: store?.dataChecked || {}, outlineData: store?.outlineData || {}, promptConfig: getPromptConfigPayload?.(), + characterCardName: charName, characterCardDescription: charDesc, + characterContactSmsHistory: getCharSmsHistory() + }); +} + +const loadAndSend = () => { const s = getOutlineStore(); if (s?.mapData) postFrame({ type: "LOAD_MAP_DATA", mapData: s.mapData }); sendSettings(); }; + +// ==================== 9. 请求处理器 ==================== + +const reply = (type, reqId, data) => postFrame({ type, requestId: reqId, ...data }); +const replyErr = (type, reqId, err) => reply(type, reqId, { error: err }); + +function getAtmosphere(store) { + return store?.outlineData?.meta?.atmosphere?.current || null; +} + +function mergeSimData(orig, upd) { + if (!upd) return orig; + const r = JSON.parse(JSON.stringify(orig || {})); + const um = upd?.meta || {}, ut = um.truth || upd?.truth, uo = um.onion_layers || ut?.onion_layers; + const ua = um.atmosphere || upd?.atmosphere, utr = um.trajectory || upd?.trajectory; + r.meta = r.meta || {}; r.meta.truth = r.meta.truth || {}; + if (ut?.driver?.tactic) r.meta.truth.driver = { ...r.meta.truth.driver, tactic: ut.driver.tactic }; + if (uo) { ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(l => { const v = uo[l]; if (Array.isArray(v) && v.length) { r.meta.onion_layers = r.meta.onion_layers || {}; r.meta.onion_layers[l] = v; } }); if (r.meta.truth?.onion_layers) delete r.meta.truth.onion_layers; } + if (um.user_guide || upd?.user_guide) r.meta.user_guide = um.user_guide || upd.user_guide; + if (ua) { r.meta.atmosphere = ua; } + if (utr) { r.meta.trajectory = utr; } + if (upd?.world) r.world = upd.world; + if (upd?.maps?.outdoor) { r.maps = r.maps || {}; r.maps.outdoor = r.maps.outdoor || {}; if (upd.maps.outdoor.description) r.maps.outdoor.description = upd.maps.outdoor.description; if (Array.isArray(upd.maps.outdoor.nodes)) { const on = r.maps.outdoor.nodes || []; upd.maps.outdoor.nodes.forEach(n => { const i = on.findIndex(x => x.name === n.name); if (i >= 0) on[i] = { ...n }; else on.push(n); }); r.maps.outdoor.nodes = on; } } + return r; +} + +async function checkAutoSim(reqId) { + const store = getOutlineStore(); + if (!store || (store.simulationProgress || 0) < (store.simulationTarget ?? 5)) return; + const data = { meta: store.outlineData?.meta || {}, world: store.outlineData?.world || null, maps: { outdoor: store.outlineData?.outdoor || null, indoor: store.outlineData?.indoor || null } }; + await handleSimWorld({ requestId: `wsim_auto_${Date.now()}`, currentData: JSON.stringify(data), isAuto: true }); +} + +const V = { + sum: o => o?.summary, npc: o => o?.name && o?.aliases, arr: o => Array.isArray(o), + scene: o => !!o?.review?.deviation && !!(o?.local_map || o?.scene_setup?.local_map), + lscene: o => !!o?.side_story, inv: o => typeof o?.invite === 'boolean' && o?.reply, + sms: o => typeof o?.reply === 'string' && o.reply.length > 0, + wg1: d => !!d && typeof d === 'object', + wg2: d => !!(d?.world && (d?.maps || d?.world?.maps)?.outdoor), + wga: d => !!(d?.world && d?.maps?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object', + lm: o => !!o?.inside?.name && !!o?.inside?.description +}; + +async function handleFetchModels({ apiUrl, apiKey }) { + try { + let models = []; + if (!apiUrl) { + for (const ep of ['/api/backends/chat-completions/models', '/api/openai/models']) { + try { const r = await fetch(ep, { headers: { 'Content-Type': 'application/json' } }); if (r.ok) { const j = await r.json(); models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); if (models.length) break; } } catch { } + } + if (!models.length) throw new Error('无法从酒馆获取模型列表'); + } else { + const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; + const r = await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); + } + postFrame({ type: "FETCH_MODELS_RESULT", models }); + } catch (e) { postFrame({ type: "FETCH_MODELS_RESULT", error: e.message }); } +} + +async function handleTestConn({ apiUrl, apiKey, model }) { + try { + if (!apiUrl) { for (const ep of ['/api/backends/chat-completions/status', '/api/openai/models', '/api/backends/chat-completions/models']) { try { if ((await fetch(ep, { headers: { 'Content-Type': 'application/json' } })).ok) { postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); return; } } catch { } } throw new Error('无法连接到酒馆API'); } + const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; + if (!(await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h })).ok) throw new Error('连接失败'); + postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); + } catch (e) { postFrame({ type: "TEST_CONN_RESULT", success: false, message: `连接失败: ${e.message}` }); } +} + +async function handleCheckUid({ uid, requestId }) { + const num = parseInt(uid, 10); + if (!uid?.trim() || isNaN(num)) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, isNaN(num) ? 'UID必须是数字' : '请输入有效的UID'); + const books = await getCharWorldbooks(); + if (!books.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, '当前角色卡没有绑定世界书'); + for (const book of books) { + const data = await loadWorldInfo(book), entry = data?.entries?.[num]; + if (entry) { + const keys = Array.isArray(entry.key) ? entry.key : []; + if (!keys.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在「${book}」中找到条目 UID ${uid},但没有主要关键字`); + return reply("CHECK_WORLDBOOK_UID_RESULT", requestId, { primaryKeys: keys, worldbook: book, comment: entry.comment || '' }); + } + } + replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在角色卡绑定的世界书中未找到 UID 为 ${uid} 的条目`); +} + +async function handleSendSms({ requestId, contactName, worldbookUid, userMessage, chatHistory, summarizedCount }) { + try { + const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; + let charContent = '', existSum = {}, sc = summarizedCount || 0; + if (worldbookUid === CHAR_CARD_UID) { + charContent = getCharInfo().desc; + const h = getCharSmsHistory(); existSum = h?.summaries || {}; sc = summarizedCount ?? h?.summarizedCount ?? 0; + } else if (worldbookUid) { + const e = await findEntry(worldbookUid); + if (e?.entry) { + const c = e.entry.content || '', si = c.indexOf('[SMS_HISTORY_START]'); + charContent = si !== -1 ? c.substring(0, si).trim() : c; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; + if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } + } + } + let histText = ''; + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + if (sumKeys.length) histText = `[之前的对话摘要] ${sumKeys.map(k => existSum[k]).join(';')}\n\n`; + if (chatHistory?.length > 1) { const msgs = chatHistory.slice(sc, -1); if (msgs.length) histText += msgs.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); } + const msgs = buildSmsMessages({ contactName, userName, storyOutline: formatOutlinePrompt(), historyCount: getCommSettings().historyCount || 50, smsHistoryContent: buildSmsHistoryContent(histText), userMessage, characterContent: charContent }); + const parsed = await callLLMJson({ messages: msgs, validate: V.sms }); + reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' }); + } catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleLoadSmsHistory({ worldbookUid }) { + if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: h?.messages || [], summarizedCount: h?.summarizedCount || 0 }); } + const store = getOutlineStore(), contact = store?.outlineData?.contacts?.find(c => c.worldbookUid === worldbookUid); + if (contact?.smsHistory?.messages?.length) return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: contact.smsHistory.messages, summarizedCount: contact.smsHistory.summarizedCount || 0 }); + const e = await findEntry(worldbookUid); let msgs = []; + if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); p?.forEach?.(i => { if (typeof i === 'string' && !i.startsWith('SMS_summary:')) { const idx = i.indexOf(':'); if (idx > 0) msgs.push({ type: i.substring(0, idx) === '{{user}}' ? 'sent' : 'received', text: i.substring(idx + 1) }); } }); } } + postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: msgs, summarizedCount: 0 }); +} + +async function handleSaveSmsHistory({ worldbookUid, messages, contactName, summarizedCount }) { + if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); if (!h) return; h.messages = Array.isArray(messages) ? messages : []; h.summarizedCount = summarizedCount || 0; if (!h.messages.length) { h.summarizedCount = 0; h.summaries = {}; } saveMetadataDebounced?.(); return; } + const e = await findEntry(worldbookUid); if (!e) return; + const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; let existSum = ''; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; + if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); existSum = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')) || ''; c = c.substring(0, s).trimEnd() + c.substring(ed + 17); } + if (messages?.length) { const sc = summarizedCount || 0, simp = messages.slice(sc).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); const arr = existSum ? [existSum, ...simp] : simp; c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; } + en.content = c.trim(); await saveWorldInfo(bookName, worldData); +} + +async function handleCompressSms({ requestId, worldbookUid, messages, contactName, summarizedCount }) { + const sc = summarizedCount || 0; + try { + const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; + let e = null, existSum = {}; + if (worldbookUid === CHAR_CARD_UID) { + const h = getCharSmsHistory(); existSum = h?.summaries || {}; + const keep = 4, toEnd = Math.max(sc, (messages?.length || 0) - keep); + if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); + const toSum = (messages || []).slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); + const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); + const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); + const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); + const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; + existSum[String(nextK)] = sum; + if (h) { h.messages = Array.isArray(messages) ? messages : (h.messages || []); h.summarizedCount = toEnd; h.summaries = existSum; saveMetadataDebounced?.(); } + return reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: toEnd }); + } + e = await findEntry(worldbookUid); + if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } } + const keep = 4, toEnd = Math.max(sc, messages.length - keep); + if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); + const toSum = messages.slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); + const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); + const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); + const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); + const newSc = toEnd; + if (e) { + const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) c = c.substring(0, s).trimEnd() + c.substring(ed + 17); + const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; + existSum[String(nextK)] = sum; + const rem = messages.slice(toEnd).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); + const arr = [`SMS_summary:${JSON.stringify(existSum)}`, ...rem]; + c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; + en.content = c.trim(); await saveWorldInfo(bookName, worldData); + } + reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: newSc }); + } catch (e) { replyErr('COMPRESS_SMS_RESULT', requestId, `压缩失败: ${e.message}`); } +} + +async function handleCheckStrangerWb({ requestId, strangerName }) { + const r = await searchEntry(strangerName); + postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) }); +} + +async function handleGenNpc({ requestId, strangerName, strangerInfo }) { + try { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡'); + const primary = char.data?.extensions?.world; + if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书'); + const comm = getCommSettings(); + const msgs = buildNpcGenerationMessages({ strangerName, strangerInfo: strangerInfo || '(无描述)', storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50 }); + const npc = await callLLMJson({ messages: msgs, validate: V.npc }); + if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据'); + const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`); + const { createWorldInfoEntry } = await import("../../../../../world-info.js"); + const newE = createWorldInfoEntry(primary, wd); if (!newE) return replyErr('GENERATE_NPC_RESULT', requestId, '创建世界书条目失败'); + Object.assign(newE, { key: npc.aliases || [npc.name], comment: npc.name, content: formatNpcToWorldbookContent(npc), constant: false, selective: true, disable: false, position: typeof comm.npcPosition === 'number' ? comm.npcPosition : 0, order: typeof comm.npcOrder === 'number' ? comm.npcOrder : 100 }); + await saveWorldInfo(primary, wd, true); + reply('GENERATE_NPC_RESULT', requestId, { success: true, npcData: npc, worldbookUid: String(newE.uid), worldbook: primary }); + } catch (e) { replyErr('GENERATE_NPC_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) { + try { + const comm = getCommSettings(); + const msgs = buildExtractStrangersMessages({ storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] }); + const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr }); + if (!Array.isArray(data)) return replyErr('EXTRACT_STRANGERS_RESULT', requestId, '提取失败:无法解析 JSON 数据'); + const strangers = data.filter(s => s?.name).map(s => ({ name: s.name, avatar: s.name[0] || '?', color: '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: s.location || '未知', info: s.info || '' })); + reply('EXTRACT_STRANGERS_RESULT', requestId, { success: true, strangers }); + } catch (e) { replyErr('EXTRACT_STRANGERS_RESULT', requestId, `提取失败: ${e.message}`); } +} + +async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) { + try { + const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildSceneSwitchMessages({ prevLocationName: prevLocationName || '未知地点', prevLocationInfo: prevLocationInfo || '', targetLocationName: targetLocationName || '未知地点', targetLocationType: targetLocationType || 'sub', targetLocationInfo: targetLocationInfo || '', storyOutline: formatOutlinePrompt(), stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, playerAction: playerAction || '', mode }); + const data = await callLLMJson({ messages: msgs, validate: V.scene }); + if (!data || !V.scene(data)) return replyErr('SCENE_SWITCH_RESULT', requestId, '场景生成失败:无法解析 JSON 数据'); + const delta = data.review?.deviation?.score_delta || 0, old = store?.deviationScore || 0, newS = Math.min(100, Math.max(0, old + delta)); + if (store) { store.deviationScore = newS; if (targetLocationType !== 'home') store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } + const lm = data.local_map || data.scene_setup?.local_map || null; + reply('SCENE_SWITCH_RESULT', requestId, { success: true, sceneData: { review: data.review, localMap: lm, strangers: [], scoreDelta: delta, newScore: newS } }); + checkAutoSim(requestId); + } catch (e) { replyErr('SCENE_SWITCH_RESULT', requestId, `场景切换失败: ${e.message}`); } +} + +async function handleExecSlash({ command }) { + try { + if (typeof command !== 'string') return; + for (const line of command.split(/\r?\n/).map(l => l.trim()).filter(Boolean)) { + if (/^\/(send|sendas|as)\b/i.test(line)) await processCommands(line); + } + } catch (e) { console.warn('[Story Outline] Slash command failed:', e); } +} + +async function handleSendInvite({ requestId, contactName, contactUid, targetLocation, smsHistory }) { + try { + const comm = getCommSettings(); + let charC = ''; + if (contactUid) { const es = Object.values(world_info?.entries || world_info || {}); charC = es.find(e => e.uid?.toString() === contactUid.toString())?.content || ''; } + const msgs = buildInviteMessages({ contactName, userName: name1 || '{{user}}', targetLocation, storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, smsHistoryContent: buildSmsHistoryContent(smsHistory || ''), characterContent: charC }); + const data = await callLLMJson({ messages: msgs, validate: V.inv }); + if (typeof data?.invite !== 'boolean') return replyErr('SEND_INVITE_RESULT', requestId, '邀请处理失败:无法解析 JSON 数据'); + reply('SEND_INVITE_RESULT', requestId, { success: true, inviteData: { accepted: data.invite, reply: data.reply, targetLocation } }); + } catch (e) { replyErr('SEND_INVITE_RESULT', requestId, `邀请处理失败: ${e.message}`); } +} + +async function handleGenLocalMap({ requestId, outdoorDescription }) { + try { + const msgs = buildLocalMapGenMessages({ storyOutline: formatOutlinePrompt(), outdoorDescription: outdoorDescription || '', historyCount: getCommSettings().historyCount || 50 }); + const data = await callLLMJson({ messages: msgs, validate: V.lm }); + if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据'); + reply('GENERATE_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); + } catch (e) { replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, `局部地图生成失败: ${e.message}`); } +} + +async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) { + try { + const store = getOutlineStore(), comm = getCommSettings(); + const msgs = buildLocalMapRefreshMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: currentLocalMap?.description || '', currentLocalMap: currentLocalMap || null, outdoorDescription: outdoorDescription || '', historyCount: comm.historyCount || 50, playerLocation: store?.playerLocation }); + const data = await callLLMJson({ messages: msgs, validate: V.lm }); + if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据'); + reply('REFRESH_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); + } catch (e) { replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, `局部地图刷新失败: ${e.message}`); } +} + +async function handleGenLocalScene({ requestId, locationName, locationInfo }) { + try { + const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildLocalSceneGenMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: locationInfo || '', stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, mode, playerLocation: store?.playerLocation }); + const data = await callLLMJson({ messages: msgs, validate: V.lscene }); + if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据'); + if (store) { store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } + const ssf = data.side_story || null, intro = ssf?.Introduce || ssf?.introduce || ''; + const ss = ssf ? (() => { const { Introduce, introduce: i2, story, ...rest } = ssf; return rest; })() : null; + reply('GENERATE_LOCAL_SCENE_RESULT', requestId, { success: true, sceneSetup: { sideStory: ss, review: data.review || null }, introduce: intro, loc: locationName }); + checkAutoSim(requestId); + } catch (e) { replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, `局部剧情生成失败: ${e.message}`); } +} + +async function handleGenWorld({ requestId, playerRequests }) { + try { + const comm = getCommSettings(), mode = getGlobalSettings().mode || 'story', store = getOutlineStore(); + const deepFind = (obj, key) => { + if (!obj || typeof obj !== 'object') return null; + if (obj[key] !== undefined) return obj[key]; + for (const v of Object.values(obj)) { + const found = deepFind(v, key); + if (found !== null) return found; + } + return null; + }; + const normalizeStep1Data = (data) => { + if (!data || typeof data !== 'object') return null; + const result = { meta: {} }; + result.meta.truth = deepFind(data, 'truth') + || (data.background && data.driver ? data : null) + || { background: deepFind(data, 'background'), driver: deepFind(data, 'driver') }; + result.meta.onion_layers = deepFind(data, 'onion_layers') || {}; + ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(k => { + const v = result.meta.onion_layers[k]; + if (v && !Array.isArray(v) && typeof v === 'object') { + result.meta.onion_layers[k] = [v]; + } + }); + result.meta.atmosphere = deepFind(data, 'atmosphere') || { reasoning: '', current: { environmental: '', npc_attitudes: '' } }; + result.meta.trajectory = deepFind(data, 'trajectory') || { reasoning: '', ending: '' }; + result.meta.user_guide = deepFind(data, 'user_guide') || { current_state: '', guides: [] }; + return result; + }; + if (mode === 'assist') { + const msgs = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, mode: 'assist' }); + const wd = await callLLMJson({ messages: msgs, validate: V.wga }); + if (!wd?.maps?.outdoor || !Array.isArray(wd.maps.outdoor.nodes)) return replyErr('GENERATE_WORLD_RESULT', requestId, '生成失败:返回数据缺少地图节点'); + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); } + return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd }); + } + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' }); + const s1m = buildWorldGenStep1Messages({ historyCount: comm.historyCount || 50, playerRequests }); + const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 })); + if (!s1d?.meta) { + return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 1 失败:无法解析大纲数据,请重试'); + } + step1Cache = { step1Data: s1d, playerRequests: playerRequests || '' }; + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: 'Step 1 完成,1 秒后开始构建世界细节 (Step 2/2)...' }); + await new Promise(r => setTimeout(r, 1000)); + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' }); + const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, step1Data: s1d }); + const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); + if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } + if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); + const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; + step1Cache = null; + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } + reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); + } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleRetryStep2({ requestId }) { + try { + if (!step1Cache?.step1Data?.meta) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 重试失败:缺少 Step 1 数据,请重新开始生成'); + const comm = getCommSettings(), store = getOutlineStore(), s1d = step1Cache.step1Data, pr = step1Cache.playerRequests || ''; + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '1 秒后重试构建世界细节 (Step 2/2)...' }); + await new Promise(r => setTimeout(r, 1000)); + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' }); + const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests: pr, step1Data: s1d }); + const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); + if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } + if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); + const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; + step1Cache = null; + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } + reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); + } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `Step 2 重试失败: ${e.message}`); } +} + +async function handleSimWorld({ requestId, currentData, isAuto }) { + try { + const store = getOutlineStore(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildWorldSimMessages({ mode, currentWorldData: currentData || '{}', historyCount: getCommSettings().historyCount || 50, deviationScore: store?.deviationScore || 0 }); + const data = await callLLMJson({ messages: msgs, validate: V.w }); + if (!data || !V.w(data)) return replyErr('SIMULATE_WORLD_RESULT', requestId, mode === 'assist' ? '世界推演失败:无法解析 JSON 数据(需包含 world 或 maps 字段)' : '世界推演失败:无法解析 JSON 数据'); + const orig = safe(() => JSON.parse(currentData)) || {}, merged = mergeSimData(orig, data); + if (store) { store.stage = (store.stage || 0) + 1; store.simulationProgress = 0; store.simulationTarget = randRange(3, 7); saveMetadataDebounced?.(); } + reply('SIMULATE_WORLD_RESULT', requestId, { success: true, simData: merged, isAuto: !!isAuto }); + } catch (e) { replyErr('SIMULATE_WORLD_RESULT', requestId, `推演失败: ${e.message}`); } +} + +function handleSaveSettings(d) { + if (d.globalSettings) saveGlobalSettings(d.globalSettings); + if (d.commSettings) saveCommSettings(d.commSettings); + const store = getOutlineStore(); + if (store) { + ['stage', 'deviationScore', 'simulationProgress', 'simulationTarget', 'playerLocation'].forEach(k => { if (d[k] !== undefined) store[k] = d[k]; }); + if (d.dataChecked) store.dataChecked = d.dataChecked; + if (d.allData) store.outlineData = d.allData; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + } + injectOutline(); +} + +function handleSavePrompts(d) { + if (!d?.promptConfig) return; + setPromptConfig?.(d.promptConfig, true); + postFrame({ + type: "PROMPT_CONFIG_UPDATED", + promptConfig: getPromptConfigPayload?.() + }); +} + +function handleSaveContacts(d) { + const store = getOutlineStore(); if (!store) return; + store.outlineData ||= {}; + if (d.contacts) store.outlineData.contacts = d.contacts; + if (d.strangers) store.outlineData.strangers = d.strangers; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + injectOutline(); +} + +function handleSaveAllData(d) { + const store = getOutlineStore(); + if (store && d.allData) { + store.outlineData = d.allData; + if (d.playerLocation !== undefined) store.playerLocation = d.playerLocation; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + injectOutline(); + } +} + +function handleSaveCharSmsHistory(d) { + const h = getCharSmsHistory(); + if (!h) return; + const sums = d?.summaries ?? d?.history?.summaries; + if (!sums || typeof sums !== 'object' || Array.isArray(sums)) return; + h.summaries = sums; + saveMetadataDebounced?.(); + injectOutline(); +} + +const handlers = { + FRAME_READY: () => { frameReady = true; flushPending(); loadAndSend(); }, + CLOSE_PANEL: hideOverlay, + SAVE_MAP_DATA: d => { const s = getOutlineStore(); if (s && d.mapData) { s.mapData = d.mapData; s.updatedAt = Date.now(); saveMetadataDebounced?.(); } }, + GET_SETTINGS: sendSettings, + SAVE_SETTINGS: handleSaveSettings, + SAVE_PROMPTS: handleSavePrompts, + SAVE_CONTACTS: handleSaveContacts, + SAVE_ALL_DATA: handleSaveAllData, + FETCH_MODELS: handleFetchModels, + TEST_CONNECTION: handleTestConn, + CHECK_WORLDBOOK_UID: handleCheckUid, + SEND_SMS: handleSendSms, + LOAD_SMS_HISTORY: handleLoadSmsHistory, + SAVE_SMS_HISTORY: handleSaveSmsHistory, + SAVE_CHAR_SMS_HISTORY: handleSaveCharSmsHistory, + COMPRESS_SMS: handleCompressSms, + CHECK_STRANGER_WORLDBOOK: handleCheckStrangerWb, + GENERATE_NPC: handleGenNpc, + EXTRACT_STRANGERS: handleExtractStrangers, + SCENE_SWITCH: handleSceneSwitch, + EXECUTE_SLASH_COMMAND: handleExecSlash, + SEND_INVITE: handleSendInvite, + GENERATE_WORLD: handleGenWorld, + RETRY_WORLD_GEN_STEP2: handleRetryStep2, + SIMULATE_WORLD: handleSimWorld, + GENERATE_LOCAL_MAP: handleGenLocalMap, + REFRESH_LOCAL_MAP: handleRefreshLocalMap, + GENERATE_LOCAL_SCENE: handleGenLocalScene +}; + +const handleMsg = ({ data }) => { if (data?.source === "LittleWhiteBox-OutlineFrame") handlers[data.type]?.(data); }; + +// ==================== 10. UI管理 ==================== + +function setupDrag(el, { onStart, onMove, onEnd, shouldHandle }) { + if (!el) return; + let state = null; + el.addEventListener('pointerdown', e => { if (shouldHandle && !shouldHandle()) return; e.preventDefault(); e.stopPropagation(); state = onStart(e); state.pointerId = e.pointerId; el.setPointerCapture(e.pointerId); }); + el.addEventListener('pointermove', e => state && onMove(e, state)); + const end = () => { if (!state) return; onEnd?.(state); try { el.releasePointerCapture(state.pointerId); } catch { } state = null; }; + ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(ev => el.addEventListener(ev, end)); +} + +function createOverlay() { + if (overlayCreated) return; + overlayCreated = true; + document.body.appendChild($(buildOverlayHtml(IFRAME_PATH))[0]); + const overlay = document.getElementById("xiaobaix-story-outline-overlay"), wrap = overlay.querySelector(".xb-so-frame-wrap"), iframe = overlay.querySelector("iframe"); + const setPtr = v => iframe && (iframe.style.pointerEvents = v); + + setupDrag(overlay.querySelector(".xb-so-drag-handle"), { + shouldHandle: () => !isMobile(), + onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sl: parseFloat(wrap.style.left), st: parseFloat(wrap.style.top) }; }, + onMove(e, s) { wrap.style.left = Math.max(0, Math.min(overlay.clientWidth - wrap.offsetWidth, s.sl + e.clientX - s.sx)) + 'px'; wrap.style.top = Math.max(0, Math.min(overlay.clientHeight - wrap.offsetHeight, s.st + e.clientY - s.sy)) + 'px'; }, + onEnd: () => setPtr('') + }); + + setupDrag(overlay.querySelector(".xb-so-resize-handle"), { + shouldHandle: () => !isMobile(), + onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sw: wrap.offsetWidth, sh: wrap.offsetHeight, ratio: wrap.offsetWidth / wrap.offsetHeight }; }, + onMove(e, s) { const dx = e.clientX - s.sx, dy = e.clientY - s.sy, delta = Math.abs(dx) > Math.abs(dy) ? dx : dy * s.ratio; let w = Math.max(400, Math.min(window.innerWidth * 0.95, s.sw + delta)), h = w / s.ratio; if (h > window.innerHeight * 0.9) { h = window.innerHeight * 0.9; w = h * s.ratio; } if (h < 300) { h = 300; w = h * s.ratio; } wrap.style.width = w + 'px'; wrap.style.height = h + 'px'; }, + onEnd: () => { setPtr(''); setStoredSize(false, { width: wrap.offsetWidth, height: wrap.offsetHeight }); } + }); + + setupDrag(overlay.querySelector(".xb-so-resize-mobile"), { + shouldHandle: () => isMobile(), + onStart(e) { setPtr('none'); return { sy: e.clientY, sh: wrap.offsetHeight }; }, + onMove(e, s) { wrap.style.height = Math.max(44, Math.min(window.innerHeight * 0.9, s.sh + e.clientY - s.sy)) + 'px'; }, + onEnd: () => { setPtr(''); setStoredSize(true, { height: wrap.offsetHeight }); } + }); + + window.addEventListener("message", handleMsg); +} + +function updateLayout() { + const wrap = document.querySelector(".xb-so-frame-wrap"); if (!wrap) return; + const drag = document.querySelector(".xb-so-drag-handle"), resize = document.querySelector(".xb-so-resize-handle"), mobile = document.querySelector(".xb-so-resize-mobile"); + if (isMobile()) { + if (drag) drag.style.display = 'none'; + if (resize) resize.style.display = 'none'; + if (mobile) mobile.style.display = 'flex'; + wrap.style.cssText = MOBILE_LAYOUT_STYLE; + const maxHeight = window.innerHeight * 1; + const stored = getStoredSize(true); + const height = stored?.height ? Math.min(stored.height, maxHeight) : maxHeight; + wrap.style.height = Math.max(44, height) + 'px'; + wrap.style.top = '0px'; + } + else { + if (drag) drag.style.display = 'block'; + if (resize) resize.style.display = 'block'; + if (mobile) mobile.style.display = 'none'; + wrap.style.cssText = DESKTOP_LAYOUT_STYLE; + const stored = getStoredSize(false); + if (stored) { + const maxW = window.innerWidth * 0.95; + const maxH = window.innerHeight * 0.9; + if (stored.width) wrap.style.width = Math.max(400, Math.min(stored.width, maxW)) + 'px'; + if (stored.height) wrap.style.height = Math.max(300, Math.min(stored.height, maxH)) + 'px'; + } + } +} + +function showOverlay() { + if (!overlayCreated) createOverlay(); + + if (!iframeLoaded) { + frameReady = false; + const f = document.getElementById("xiaobaix-story-outline-iframe"); + if (f) f.src = IFRAME_PATH; + iframeLoaded = true; + updateLayout(); + } + + $("#xiaobaix-story-outline-overlay").show(); +} + +function hideOverlay() { + $("#xiaobaix-story-outline-overlay").hide(); +} + +let lastIsMobile = isMobile(); +window.addEventListener('resize', () => { const nowIsMobile = isMobile(); if (nowIsMobile !== lastIsMobile) { lastIsMobile = nowIsMobile; updateLayout(); } }); + +// ==================== 11. 事件与初始化 ==================== + +let eventsRegistered = false; + +function addBtnToMsg(mesId) { + if (!getSettings().storyOutline?.enabled) return; + const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`); + if (!msg || msg.querySelector('.xiaobaix-story-outline-btn')) return; + const btn = document.createElement('div'); + btn.className = 'mes_btn xiaobaix-story-outline-btn'; + btn.title = '小白板'; + btn.dataset.mesid = mesId; + btn.innerHTML = ''; + btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (!getSettings().storyOutline?.enabled) return; currentMesId = Number(mesId); showOverlay(); loadAndSend(); }); + if (window.registerButtonToSubContainer?.(mesId, btn)) return; + msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn); +} + +function initBtns() { + if (!getSettings().storyOutline?.enabled) return; + $("#chat .mes").each((_, el) => { const id = el.getAttribute("mesid"); if (id != null) addBtnToMsg(id); }); +} + +function registerEvents() { + if (eventsRegistered) return; + eventsRegistered = true; + initBtns(); + events.on(event_types.CHAT_CHANGED, () => { setTimeout(initBtns, 80); setTimeout(injectOutline, 100); }); + events.on(event_types.GENERATION_STARTED, injectOutline); + const handler = d => setTimeout(() => { + const id = d?.element ? $(d.element).attr("mesid") : d?.messageId; + id == null ? initBtns() : addBtnToMsg(id); + }, 50); + events.onMany([ + event_types.USER_MESSAGE_RENDERED, + event_types.CHARACTER_MESSAGE_RENDERED, + event_types.MESSAGE_RECEIVED, + event_types.MESSAGE_UPDATED, + event_types.MESSAGE_SWIPED, + event_types.MESSAGE_EDITED + ], handler); + setupSTEvents(); +} + +function cleanup() { + events.cleanup(); + eventsRegistered = false; + $(".xiaobaix-story-outline-btn").remove(); + hideOverlay(); + overlayCreated = false; frameReady = false; pendingMsgs = []; + iframeLoaded = false; + window.removeEventListener("message", handleMsg); + document.getElementById("xiaobaix-story-outline-overlay")?.remove(); + removePrompt(); + if (presetCleanup) { presetCleanup(); presetCleanup = null; } +} + +$(document).on("xiaobaix:storyOutline:toggle", (_e, enabled) => { + if (enabled) { + registerEvents(); + initBtns(); + injectOutline(); + } else { + cleanup(); + } +}); + +document.addEventListener('xiaobaixEnabledChanged', e => { + if (!e?.detail?.enabled) { + cleanup(); + } else if (getSettings().storyOutline?.enabled) { + registerEvents(); + initBtns(); + injectOutline(); + } +}); + +jQuery(() => { + if (!getSettings().storyOutline?.enabled) return; + registerEvents(); + setTimeout(injectOutline, 200); + window.registerModuleCleanup?.('storyOutline', cleanup); +}); + +export { cleanup }; diff --git a/modules/story-summary/story-summary.html b/modules/story-summary/story-summary.html index 46bb400..948eaca 100644 --- a/modules/story-summary/story-summary.html +++ b/modules/story-summary/story-summary.html @@ -437,6 +437,27 @@ body { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } +.stat-warning { + font-size: 0.625rem; + color: #ff9800; + margin-top: 4px; +} +#keep-visible-count { + width: 32px; + padding: 2px 4px; + margin: 0 2px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + font-size: inherit; + font-weight: bold; + color: var(--highlight); + text-align: center; + border-radius: 3px; +} +#keep-visible-count:focus { + border-color: var(--accent); + outline: none; +} @@ -458,14 +479,17 @@ body {
0
待总结
+
+ 聊天时隐藏已总结 · 0 楼(保留 + + 楼) + -
-
- ${['character','global'].map(t=>` -
-
-
${t==='character'?' 本地变量':' 全局变量'}
-
- ${[['import','fa-upload','导入变量'],['export','fa-download','导出变量'],['add','fa-plus','添加变量'],['collapse','fa-chevron-down','展开/折叠所有'],['clear-all','fa-trash','清除所有变量']].map(([a,ic,ti])=>``).join('')} -
-
-
-
-
-
-
-
- - -
-
-
`).join('')} -
- -`; - -const VT = { - character: { getter: getLocalVariable, setter: setLocalVariable, storage: ()=> chat_metadata?.variables || (chat_metadata.variables = {}), save: saveMetadataDebounced }, - global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced }, -}; - -const LWB_RULES_KEY='LWB_RULES'; -const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } }; -const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } }; -const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined); -const hasAnyRule = (n)=>{ - if(!n) return false; - if(n.ro) return true; - if(n.objectPolicy && n.objectPolicy!=='none') return true; - if(n.arrayPolicy && n.arrayPolicy!=='lock') return true; - const c=n.constraints||{}; - return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source); -}; -const ruleTip = (n)=>{ - if(!n) return ''; - const lines=[], c=n.constraints||{}; - if(n.ro) lines.push('只读:$ro'); - if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext(可增键)',prune:'$prune(可删键)',free:'$free(可增删键)'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); } - if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow(可增项)',shrink:'$shrink(可删项)',list:'$list(可增删项)'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); } - if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); } - if('step'in c) lines.push(`步长:$step=${c.step}`); - if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`); - if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`); - return lines.join('\n'); -}; -const badgesHtml = (n)=>{ - if(!hasAnyRule(n)) return ''; - const tip=ruleTip(n).replace(/"/g,'"'), out=[]; - if(n.ro) out.push(``); - if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(``); - const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(``); - return out.length?`${out.join('')}`:''; -}; -const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}}; - -class VariablesPanel { - constructor(){ - this.state={isOpen:false,isEnabled:false,container:null,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''}; - this.variableSnapshot=null; this.eventHandlers={}; this.savingInProgress=false; this.containerHtml=EMBEDDED_HTML; - } - - async init(){ - this.injectUI(); this.bindControlToggle(); - const s=this.getSettings(); this.state.isEnabled=s.enabled; this.syncCheckbox(); - if(s.enabled) this.enable(); - } - - injectUI(){ - if(!document.getElementById('variables-panel-css')){ - const st=document.createElement('style'); st.id='variables-panel-css'; st.textContent=EMBEDDED_CSS; document.head.appendChild(st); - } - } - - getSettings(){ extension_settings.LittleWhiteBox ??= {}; return extension_settings.LittleWhiteBox.variablesPanel ??= {...CONFIG.defaultSettings}; } - vt(t){ return VT[t]; } - store(t){ return this.vt(t).storage(); } - - enable(){ - this.createContainer(); this.bindEvents(); - ['character','global'].forEach(t=>this.normalizeStore(t)); - this.loadVariables(); this.installMessageButtons(); - } - disable(){ this.cleanup(); } - - cleanup(){ - this.stopWatcher(); this.unbindEvents(); this.unbindControlToggle(); this.removeContainer(); this.removeAllMessageButtons(); - const tm=this.state.timers; if(tm.watcher) clearInterval(tm.watcher); if(tm.longPress) clearTimeout(tm.longPress); - tm.touch.forEach(x=>clearTimeout(x)); tm.touch.clear(); - Object.assign(this.state,{isOpen:false,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''}); - this.variableSnapshot=null; this.savingInProgress=false; - } - - createContainer(){ - if(!this.state.container?.length){ - $('body').append(this.containerHtml); - this.state.container=$("#vm-container"); - $("#vm-close").off('click').on('click',()=>this.close()); - } - } - removeContainer(){ this.state.container?.remove(); this.state.container=null; } - - open(){ - if(!this.state.isEnabled) return toastr.warning('请先启用变量面板'); - this.createContainer(); this.bindEvents(); this.state.isOpen=true; this.state.container.show(); - this.state.rulesChecksum = JSON.stringify(getRulesTable()||{}); - this.loadVariables(); this.startWatcher(); - } - close(){ this.state.isOpen=false; this.stopWatcher(); this.unbindEvents(); this.removeContainer(); } - - bindControlToggle(){ - const id='xiaobaix_variables_panel_enabled'; - const bind=()=>{ - const cb=document.getElementById(id); if(!cb) return false; - this.handleCheckboxChange && cb.removeEventListener('change',this.handleCheckboxChange); - this.handleCheckboxChange=e=> this.toggleEnabled(e.target instanceof HTMLInputElement ? !!e.target.checked : false); - cb.addEventListener('change',this.handleCheckboxChange); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; return true; - }; - if(!bind()) setTimeout(bind,100); - } - unbindControlToggle(){ - const cb=document.getElementById('xiaobaix_variables_panel_enabled'); - if(cb && this.handleCheckboxChange) cb.removeEventListener('change',this.handleCheckboxChange); - this.handleCheckboxChange=null; - } - syncCheckbox(){ const cb=document.getElementById('xiaobaix_variables_panel_enabled'); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; } - - bindEvents(){ - if(!this.state.container?.length) return; - this.unbindEvents(); - const ns='.vm'; - $(document) - .on(`click${ns}`,'.vm-section [data-act]',e=>this.onHeaderAction(e)) - .on(`touchstart${ns}`,'.vm-item>.vm-item-header',e=>this.handleTouch(e)) - .on(`click${ns}`,'.vm-item>.vm-item-header',e=>this.handleItemClick(e)) - .on(`click${ns}`,'.vm-item-controls [data-act]',e=>this.onItemAction(e)) - .on(`click${ns}`,'.vm-inline-form [data-act]',e=>this.onInlineAction(e)) - .on(`mousedown${ns} touchstart${ns}`,'[data-act="copy"]',e=>this.bindCopyPress(e)); - ['character','global'].forEach(t=> $(`#${t}-vm-search`).on('input',e=>{ - if(e.currentTarget instanceof HTMLInputElement) this.searchVariables(t,e.currentTarget.value); - else this.searchVariables(t,''); - })); - } - unbindEvents(){ $(document).off('.vm'); ['character','global'].forEach(t=> $(`#${t}-vm-search`).off('input')); } - - onHeaderAction(e){ - e.preventDefault(); e.stopPropagation(); - const b=$(e.currentTarget), act=b.data('act'), t=b.data('type'); - ({ - import:()=>this.importVariables(t), - export:()=>this.exportVariables(t), - add:()=>this.showAddForm(t), - collapse:()=>this.collapseAll(t), - 'clear-all':()=>this.clearAllVariables(t), - 'save-add':()=>this.saveAddVariable(t), - 'cancel-add':()=>this.hideAddForm(t), - }[act]||(()=>{}))(); - } - - onItemAction(e){ - e.preventDefault(); e.stopPropagation(); - const btn=$(e.currentTarget), act=btn.data('act'), item=btn.closest('.vm-item'), - t=this.getVariableType(item), path=this.getItemPath(item); - ({ - edit: ()=>this.editAction(item,'edit',t,path), - 'add-child': ()=>this.editAction(item,'addChild',t,path), - delete: ()=>this.handleDelete(item,t,path), - copy: ()=>{} - }[act]||(()=>{}))(); - } - - onInlineAction(e){ e.preventDefault(); e.stopPropagation(); const act=$(e.currentTarget).data('act'); act==='inline-save'? this.handleInlineSave($(e.currentTarget).closest('.vm-inline-form')) : this.hideInlineForm(); } - - bindCopyPress(e){ - e.preventDefault(); e.stopPropagation(); - const start=Date.now(); - this.state.timers.longPress=setTimeout(()=>{ this.handleCopy(e,true); this.state.timers.longPress=null; },CONFIG.longPressDelay); - const release=(re)=>{ - if(this.state.timers.longPress){ - clearTimeout(this.state.timers.longPress); this.state.timers.longPress=null; - if(re.type!=='mouseleave' && (Date.now()-start) this.state.isOpen && this.checkChanges(), CONFIG.watchInterval); } - stopWatcher(){ if(this.state.timers.watcher){ clearInterval(this.state.timers.watcher); this.state.timers.watcher=null; } } - - updateSnapshot(){ this.variableSnapshot={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; } - - expandChangedKeys(changed){ - ['character','global'].forEach(t=>{ - const set=changed[t]; if(!set?.size) return; - setTimeout(()=>{ - const list=$(`#${t}-variables-list .vm-item[data-key]`); - set.forEach(k=> list.filter((_,el)=>$(el).data('key')===k).addClass('expanded')); - },10); - }); - } - - checkChanges(){ - try{ - const sum=JSON.stringify(getRulesTable()||{}); - if(sum!==this.state.rulesChecksum){ - this.state.rulesChecksum=sum; - const keep=this.saveAllExpandedStates(); - this.loadVariables(); this.restoreAllExpandedStates(keep); - } - const cur={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; - const changed={character:new Set(), global:new Set()}; - ['character','global'].forEach(t=>{ - const prev=this.variableSnapshot?.[t]||{}, now=cur[t]; - new Set([...Object.keys(prev),...Object.keys(now)]).forEach(k=>{ if(!(k in prev)||!(k in now)||prev[k]!==now[k]) changed[t].add(k);}); - }); - if(changed.character.size||changed.global.size){ - const keep=this.saveAllExpandedStates(); - this.variableSnapshot=cur; this.loadVariables(); this.restoreAllExpandedStates(keep); this.expandChangedKeys(changed); - } - }catch{} - } - - loadVariables(){ - ['character','global'].forEach(t=>{ - this.renderVariables(t); - $(`#${t}-variables-section [data-act="collapse"] i`).removeClass('fa-chevron-up').addClass('fa-chevron-down'); - }); - } - - renderVariables(t){ - const c=$(`#${t}-variables-list`).empty(), s=this.store(t), root=Object.entries(s); - if(!root.length) c.append('
暂无变量
'); - else root.forEach(([k,v])=> c.append(this.createVariableItem(t,k,v,0,[k]))); - } - - createVariableItem(t,k,v,l=0,fullPath=[]){ - const parsed=this.parseValue(v), hasChildren=typeof parsed==='object' && parsed!==null; - const disp = l===0? this.formatTopLevelValue(v) : this.formatValue(v); - const ruleNode=getRuleNodeByPath(fullPath); - return $(`
0?`data-level="${l}"`:''} data-path="${this.escape(pathKey(fullPath))}"> -
-
${this.escape(k)}${badgesHtml(ruleNode)}:
-
${disp}
-
${this.createButtons()}
-
- ${hasChildren?`
${this.renderChildren(parsed,l+1,fullPath)}
`:''} -
`); - } - - createButtons(){ - return [ - ['edit','fa-edit','编辑'], - ['add-child','fa-plus-circle','添加子变量'], - ['copy','fa-eye-dropper','复制(长按: 宏,单击: 变量路径)'], - ['delete','fa-trash','删除'], - ].map(([act,ic,ti])=>``).join(''); - } - - createInlineForm(t,target,fs){ - const fid=`inline-form-${Date.now()}`; - const inf=$(` -
-
-
-
- - -
-
`); - this.state.currentInlineForm?.remove(); - target.after(inf); this.state.currentInlineForm=inf; this.state.formState={...fs,formId:fid,targetItem:target}; - const ta=inf.find('.inline-value'); ta.on('input',()=>this.autoResizeTextarea(ta)); - setTimeout(()=>{ inf.addClass('active'); inf.find('.inline-name').focus(); },10); - return inf; - } - - renderChildren(obj,level,parentPath){ return Object.entries(obj).map(([k,v])=> this.createVariableItem(null,k,v,level,[...(parentPath||[]),k])[0].outerHTML).join(''); } - - handleTouch(e){ - if($(e.target).closest('.vm-item-controls').length) return; - e.stopPropagation(); - const item=$(e.currentTarget).closest('.vm-item'); $('.vm-item').removeClass('touched'); item.addClass('touched'); - this.clearTouchTimer(item); - const t=setTimeout(()=>{ item.removeClass('touched'); this.state.timers.touch.delete(item[0]); },CONFIG.touchTimeout); - this.state.timers.touch.set(item[0],t); - } - clearTouchTimer(i){ const t=this.state.timers.touch.get(i[0]); if(t){ clearTimeout(t); this.state.timers.touch.delete(i[0]); } } - - handleItemClick(e){ - if($(e.target).closest('.vm-item-controls').length) return; - e.stopPropagation(); - $(e.currentTarget).closest('.vm-item').toggleClass('expanded'); - } - - async writeClipboard(txt){ - try{ - if(navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(txt); - else { const ta=document.createElement('textarea'); Object.assign(ta.style,{position:'fixed',top:0,left:0,width:'2em',height:'2em',padding:0,border:'none',outline:'none',boxShadow:'none',background:'transparent'}); ta.value=txt; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } - return true; - }catch{ return false; } - } - - handleCopy(e,longPress){ - const item=$(e.target).closest('.vm-item'), path=this.getItemPath(item), t=this.getVariableType(item), level=parseInt(item.attr('data-level'))||0; - const formatted=this.formatPath(t,path); let cmd=''; - if(longPress){ - if(t==='character'){ - cmd = level===0 ? `{{getvar::${path[0]}}}` : `{{xbgetvar::${formatted}}}`; - }else{ - cmd = `{{getglobalvar::${path[0]}}}`; - if(level>0) toastr.info('全局变量宏暂不支持子路径,已复制顶级变量'); - } - }else cmd=formatted; - (async()=> (await this.writeClipboard(cmd)) ? toastr.success(`已复制: ${cmd}`) : toastr.error('复制失败'))(); - } - - editAction(item,action,type,path){ - const inf=this.createInlineForm(type,item,{action,path,type}); - if(action==='edit'){ - const v=this.getValueByPath(type,path); - setTimeout(()=>{ - inf.find('.inline-name').val(path[path.length-1]); - const ta=inf.find('.inline-value'); - const fill=(val)=> Array.isArray(val)? (val.length===1 ? String(val[0]??'') : JSON.stringify(val,null,2)) : (val&&typeof val==='object'? JSON.stringify(val,null,2) : String(val??'')); - ta.val(fill(v)); this.autoResizeTextarea(ta); - },50); - }else if(action==='addChild'){ - inf.find('.inline-name').attr('placeholder',`为 "${path.join('.')}" 添加子变量名称`); - inf.find('.inline-value').attr('placeholder','子变量值 (支持JSON格式)'); - } - } - - handleDelete(_item,t,path){ - const n=path[path.length-1]; - if(!confirm(`确定要删除 "${n}" 吗?`)) return; - this.withGlobalRefresh(()=> this.deleteByPathSilently(t,path)); - toastr.success('变量已删除'); - } - - refreshAndKeep(t,states){ this.vt(t).save(); this.loadVariables(); this.updateSnapshot(); states && this.restoreExpandedStates(t,states); } - withPreservedExpansion(t,fn){ const s=this.saveExpandedStates(t); fn(); this.refreshAndKeep(t,s); } - withGlobalRefresh(fn){ const s=this.saveAllExpandedStates(); fn(); this.loadVariables(); this.updateSnapshot(); this.restoreAllExpandedStates(s); } - - handleInlineSave(form){ - if(this.savingInProgress) return; this.savingInProgress=true; - try{ - if(!form?.length) return toastr.error('表单未找到'); - const rawName=form.find('.inline-name').val(); - const rawValue=form.find('.inline-value').val(); - const name= typeof rawName==='string'? rawName.trim() : String(rawName ?? '').trim(); - const value= typeof rawValue==='string'? rawValue.trim() : String(rawValue ?? '').trim(); - const type=form.data('type'); - if(!name) return form.find('.inline-name').focus(), toastr.error('请输入变量名称'); - const val=this.processValue(value), {action,path}=this.state.formState; - this.withPreservedExpansion(type,()=>{ - if(action==='addChild') { - this.setValueByPath(type,[...path,name],val); - } else if(action==='edit'){ - const old=path[path.length-1]; - if(name!==old){ - this.deleteByPathSilently(type,path); - if(path.length===1) { - const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; - this.vt(type).setter(name,toSave); - } else { - this.setValueByPath(type,[...path.slice(0,-1),name],val); - } - } else { - this.setValueByPath(type,path,val); - } - } else { - const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; - this.vt(type).setter(name,toSave); - } - }); - this.hideInlineForm(); toastr.success('变量已保存'); - }catch(e){ toastr.error('JSON格式错误: '+e.message); } - finally{ this.savingInProgress=false; } - } - hideInlineForm(){ if(this.state.currentInlineForm){ this.state.currentInlineForm.removeClass('active'); setTimeout(()=>{ this.state.currentInlineForm?.remove(); this.state.currentInlineForm=null; },200);} this.state.formState={}; } - - showAddForm(t){ - this.hideInlineForm(); - const f=$(`#${t}-vm-add-form`).addClass('active'), ta=$(`#${t}-vm-value`); - $(`#${t}-vm-name`).val('').attr('placeholder','变量名称').focus(); - ta.val('').attr('placeholder','变量值 (支持JSON格式)'); - if(!ta.data('auto-resize-bound')){ ta.on('input',()=>this.autoResizeTextarea(ta)); ta.data('auto-resize-bound',true); } - } - hideAddForm(t){ $(`#${t}-vm-add-form`).removeClass('active'); $(`#${t}-vm-name, #${t}-vm-value`).val(''); this.state.formState={}; } - - saveAddVariable(t){ - if(this.savingInProgress) return; this.savingInProgress=true; - try{ - const rawN=$(`#${t}-vm-name`).val(); - const rawV=$(`#${t}-vm-value`).val(); - const n= typeof rawN==='string' ? rawN.trim() : String(rawN ?? '').trim(); - const v= typeof rawV==='string' ? rawV.trim() : String(rawV ?? '').trim(); - if(!n) return toastr.error('请输入变量名称'); - const val=this.processValue(v); - this.withPreservedExpansion(t,()=> { - const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; - this.vt(t).setter(n,toSave); - }); - this.hideAddForm(t); toastr.success('变量已保存'); - }catch(e){ toastr.error('JSON格式错误: '+e.message); } - finally{ this.savingInProgress=false; } - } - - getValueByPath(t,p){ if(p.length===1) return this.vt(t).getter(p[0]); let v=this.parseValue(this.vt(t).getter(p[0])); p.slice(1).forEach(k=> v=v?.[k]); return v; } - - setValueByPath(t,p,v){ - if(p.length===1){ - const toSave = (typeof v==='object' && v!==null) ? JSON.stringify(v) : v; - this.vt(t).setter(p[0], toSave); - return; - } - let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) root={}; - let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; }); - cur[p[p.length-1]]=v; this.vt(t).setter(p[0], JSON.stringify(root)); - } - - deleteByPathSilently(t,p){ - if(p.length===1){ delete this.store(t)[p[0]]; return; } - let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) return; - let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; }); - delete cur[p[p.length-1]]; this.vt(t).setter(p[0], JSON.stringify(root)); - } - - formatPath(t,path){ - if(!Array.isArray(path)||!path.length) return ''; - let out=String(path[0]), cur=this.parseValue(this.vt(t).getter(path[0])); - for(let i=1;i[${c} items]`; } return this.formatValue(p); } - formatValue(v){ if(v==null) return `${v}`; const e=this.escape(String(v)); return `${e.length>50? e.substring(0,50)+'...' : e}`; } - escape(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; } - autoResizeTextarea(ta){ if(!ta?.length) return; const el=ta[0]; el.style.height='auto'; const sh=el.scrollHeight, max=Math.min(300,window.innerHeight*0.4), fh=Math.max(60,Math.min(max,sh+4)); el.style.height=fh+'px'; el.style.overflowY=sh>max-4?'auto':'hidden'; } - searchVariables(t,q){ const l=q.toLowerCase().trim(); $(`#${t}-variables-list .vm-item`).each(function(){ $(this).toggle(!l || $(this).text().toLowerCase().includes(l)); }); } - collapseAll(t){ const items=$(`#${t}-variables-list .vm-item`), icon=$(`#${t}-variables-section [data-act="collapse"] i`); const any=items.filter('.expanded').length>0; items.toggleClass('expanded',!any); icon.toggleClass('fa-chevron-up',!any).toggleClass('fa-chevron-down',any); } - - clearAllVariables(t){ - if(!confirm(`确定要清除所有${t==='character'?'角色':'全局'}变量吗?`)) return; - this.withPreservedExpansion(t,()=>{ const s=this.store(t); Object.keys(s).forEach(k=> delete s[k]); }); - toastr.success('变量已清除'); - } - - async importVariables(t){ - const inp=document.createElement('input'); inp.type='file'; inp.accept='.json'; - inp.onchange=async(e)=>{ - try{ - const tgt=e.target; - const file = (tgt && 'files' in tgt && tgt.files && tgt.files[0]) ? tgt.files[0] : null; - if(!file) throw new Error('未选择文件'); - const txt=await file.text(), v=JSON.parse(txt); - this.withPreservedExpansion(t,()=> { - Object.entries(v).forEach(([k,val])=> { - const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; - this.vt(t).setter(k,toSave); - }); - }); - toastr.success(`成功导入 ${Object.keys(v).length} 个变量`); - }catch{ toastr.error('文件格式错误'); } - }; - inp.click(); - } - - exportVariables(t){ - const v=this.store(t), b=new Blob([JSON.stringify(v,null,2)],{type:'application/json'}), a=document.createElement('a'); - a.href=URL.createObjectURL(b); a.download=`${t}-variables-${new Date().toISOString().split('T')[0]}.json`; a.click(); - toastr.success('变量已导出'); - } - - saveExpandedStates(t){ const s=new Set(); $(`#${t}-variables-list .vm-item.expanded`).each(function(){ const k=$(this).data('key'); if(k!==undefined) s.add(String(k)); }); return s; } - saveAllExpandedStates(){ return { character:this.saveExpandedStates('character'), global:this.saveExpandedStates('global') }; } - restoreExpandedStates(t,s){ if(!s?.size) return; setTimeout(()=>{ $(`#${t}-variables-list .vm-item`).each(function(){ const k=$(this).data('key'); if(k!==undefined && s.has(String(k))) $(this).addClass('expanded'); }); },50); } - restoreAllExpandedStates(st){ Object.entries(st).forEach(([t,s])=> this.restoreExpandedStates(t,s)); } - - toggleEnabled(en){ - const s=this.getSettings(); s.enabled=this.state.isEnabled=en; saveSettingsDebounced(); this.syncCheckbox(); - en ? (this.enable(),this.open()) : this.disable(); - } - - createPerMessageBtn(messageId){ - const btn=document.createElement('div'); - btn.className='mes_btn mes_variables_panel'; - btn.title='变量面板'; - btn.dataset.mid=messageId; - btn.innerHTML=''; - btn.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); this.open(); }); - return btn; - } - - addButtonToMessage(messageId){ - const msg=$(`#chat .mes[mesid="${messageId}"]`); - if(!msg.length || msg.find('.mes_btn.mes_variables_panel').length) return; - const btn=this.createPerMessageBtn(messageId); - const appendToFlex=(m)=>{ const flex=m.find('.flex-container.flex1.alignitemscenter'); if(flex.length) flex.append(btn); }; - if(typeof window['registerButtonToSubContainer']==='function'){ - const ok=window['registerButtonToSubContainer'](messageId,btn); - if(!ok) appendToFlex(msg); - } else appendToFlex(msg); - } - - addButtonsToAllMessages(){ $('#chat .mes').each((_,el)=>{ const mid=el.getAttribute('mesid'); if(mid) this.addButtonToMessage(mid); }); } - removeAllMessageButtons(){ $('#chat .mes .mes_btn.mes_variables_panel').remove(); } - - installMessageButtons(){ - const delayedAdd=(id)=> setTimeout(()=>{ if(id!=null) this.addButtonToMessage(id); },120); - const delayedScan=()=> setTimeout(()=> this.addButtonsToAllMessages(),150); - this.removeMessageButtonsListeners(); - const idFrom=(d)=> typeof d==='object' ? (d.messageId||d.id) : d; - - if (!this.msgEvents) this.msgEvents = createModuleEvents('variablesPanel:messages'); - - this.msgEvents.onMany([ - event_types.USER_MESSAGE_RENDERED, - event_types.CHARACTER_MESSAGE_RENDERED, - event_types.MESSAGE_RECEIVED, - event_types.MESSAGE_UPDATED, - event_types.MESSAGE_SWIPED, - event_types.MESSAGE_EDITED - ].filter(Boolean), (d) => delayedAdd(idFrom(d))); - - this.msgEvents.on(event_types.MESSAGE_SENT, debounce(() => delayedScan(), 300)); - this.msgEvents.on(event_types.CHAT_CHANGED, () => delayedScan()); - - this.addButtonsToAllMessages(); - } - - removeMessageButtonsListeners(){ - if (this.msgEvents) { - this.msgEvents.cleanup(); - } - } - - removeMessageButtons(){ this.removeMessageButtonsListeners(); this.removeAllMessageButtons(); } - - normalizeStore(t){ - const s=this.store(t); let changed=0; - for(const[k,v] of Object.entries(s)){ - if(typeof v==='object' && v!==null){ - try{ s[k]=JSON.stringify(v); changed++; }catch{} - } - } - if(changed) this.vt(t).save?.(); - } -} - -let variablesPanelInstance=null; - -export async function initVariablesPanel(){ - try{ - extension_settings.variables ??= { global:{} }; - if(variablesPanelInstance) variablesPanelInstance.cleanup(); - variablesPanelInstance=new VariablesPanel(); - await variablesPanelInstance.init(); - return variablesPanelInstance; - }catch(e){ - console.error(`[${CONFIG.extensionName}] 加载失败:`,e); - toastr?.error?.('Variables Panel加载失败'); - throw e; - } -} - -export function getVariablesPanelInstance(){ return variablesPanelInstance; } -export function cleanupVariablesPanel(){ if(variablesPanelInstance){ variablesPanelInstance.removeMessageButtons(); variablesPanelInstance.cleanup(); variablesPanelInstance=null; } } +import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js"; +import { saveSettingsDebounced, chat_metadata } from "../../../../../../script.js"; +import { getLocalVariable, setLocalVariable, getGlobalVariable, setGlobalVariable } from "../../../../../variables.js"; +import { extensionFolderPath } from "../../core/constants.js"; +import { createModuleEvents, event_types } from "../../core/event-manager.js"; + +const CONFIG = { + extensionName: "variables-panel", + extensionFolderPath, + defaultSettings: { enabled: false }, + watchInterval: 1500, touchTimeout: 4000, longPressDelay: 700, +}; + +const EMBEDDED_CSS = ` +.vm-container{color:var(--SmartThemeBodyColor);background:var(--SmartThemeBlurTintColor);flex-direction:column;overflow-y:auto;z-index:3000;position:fixed;display:none} +.vm-container:not([style*="display: none"]){display:flex} +@media (min-width: 1000px){.vm-container:not([style*="display: none"]){width:calc((100vw - var(--sheldWidth)) / 2);border-left:1px solid var(--SmartThemeBorderColor);right:0;top:0;height:100vh}} +@media (max-width: 999px){.vm-container:not([style*="display: none"]){max-height:calc(100svh - var(--topBarBlockSize));top:var(--topBarBlockSize);width:100%;height:100vh;left:0}} +.vm-header,.vm-section,.vm-item-content{border-bottom:.5px solid var(--SmartThemeBorderColor)} +.vm-header,.vm-section-header{display:flex;justify-content:space-between;align-items:center} +.vm-title,.vm-item-name{font-weight:bold} +.vm-header{padding:15px}.vm-title{font-size:16px} +.vm-section-header{padding:5px 15px;border-bottom:5px solid var(--SmartThemeBorderColor);font-size:14px;color:var(--SmartThemeEmColor)} +.vm-close,.vm-btn{background:none;border:none;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} +.vm-close{font-size:18px;padding:5px} +.vm-btn{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;font-size:12px;padding:2px 4px;color:var(--SmartThemeBodyColor)} +.vm-search-container{padding:10px;border-bottom:1px solid var(--SmartThemeBorderColor)} +.vm-search-input{width:100%;padding:3px 6px} +.vm-clear-all-btn{color:#ff6b6b;border-color:#ff6b6b;opacity:.3} +.vm-list{flex:1;overflow-y:auto;padding:10px} +.vm-item{border:1px solid var(--SmartThemeBorderColor);opacity:.7} +.vm-item.expanded{opacity:1} +.vm-item-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;padding-left:5px} +.vm-item-name{font-size:13px} +.vm-item-controls{background:var(--SmartThemeChatTintColor);display:flex;gap:5px;position:absolute;right:5px;opacity:0;visibility:hidden} +.vm-item-content{border-top:1px solid var(--SmartThemeBorderColor);display:none} +.vm-item.expanded>.vm-item-content{display:block} +.vm-inline-form{background:var(--SmartThemeChatTintColor);border:1px solid var(--SmartThemeBorderColor);border-top:none;padding:10px;margin:0;display:none} +.vm-inline-form.active{display:block;animation:slideDown .2s ease-out} +@keyframes slideDown{from{opacity:0;max-height:0;padding-top:0;padding-bottom:0}to{opacity:1;max-height:200px;padding-top:10px;padding-bottom:10px}} +@media (hover:hover){.vm-close:hover,.vm-btn:hover{opacity:.8}.vm-close:hover{color:red}.vm-clear-all-btn:hover{opacity:1}.vm-item:hover>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-list:hover::-webkit-scrollbar-thumb{background:var(--SmartThemeQuoteColor)}.vm-variable-checkbox:hover{background-color:rgba(255,255,255,.1)}} +@media (hover:none){.vm-close:active,.vm-btn:active{opacity:.8}.vm-close:active{color:red}.vm-clear-all-btn:active{opacity:1}.vm-item:active>.vm-item-header .vm-item-controls,.vm-item.touched>.vm-item-header .vm-item-controls{opacity:1;visibility:visible}.vm-item.touched>.vm-item-header{background-color:rgba(255,255,255,.05)}.vm-btn:active{background-color:rgba(255,255,255,.1);transform:scale(.95)}.vm-variable-checkbox:active{background-color:rgba(255,255,255,.1)}} +.vm-item:not([data-level]).expanded .vm-item[data-level="1"]{--level-color:hsl(36,100%,50%)} +.vm-item[data-level="1"].expanded .vm-item[data-level="2"]{--level-color:hsl(60,100%,50%)} +.vm-item[data-level="2"].expanded .vm-item[data-level="3"]{--level-color:hsl(120,100%,50%)} +.vm-item[data-level="3"].expanded .vm-item[data-level="4"]{--level-color:hsl(180,100%,50%)} +.vm-item[data-level="4"].expanded .vm-item[data-level="5"]{--level-color:hsl(240,100%,50%)} +.vm-item[data-level="5"].expanded .vm-item[data-level="6"]{--level-color:hsl(280,100%,50%)} +.vm-item[data-level="6"].expanded .vm-item[data-level="7"]{--level-color:hsl(320,100%,50%)} +.vm-item[data-level="7"].expanded .vm-item[data-level="8"]{--level-color:hsl(200,100%,50%)} +.vm-item[data-level="8"].expanded .vm-item[data-level="9"]{--level-color:hsl(160,100%,50%)} +.vm-item[data-level]{border-left:2px solid var(--level-color);margin-left:6px} +.vm-item[data-level]:last-child{border-bottom:2px solid var(--level-color)} +.vm-tree-value,.vm-variable-checkbox span{font-family:monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.vm-tree-value{color:inherit;font-size:12px;flex:1;margin:0 10px} +.vm-input,.vm-textarea{border:1px solid var(--SmartThemeBorderColor);border-radius:3px;background-color:var(--SmartThemeChatTintColor);font-size:12px;margin:3px 0} +.vm-textarea{min-height:60px;padding:5px;font-family:monospace;resize:vertical} +.vm-add-form{padding:10px;border-top:1px solid var(--SmartThemeBorderColor);display:none} +.vm-add-form.active{display:block} +.vm-form-row{display:flex;gap:10px;margin-bottom:10px;align-items:center} +.vm-form-label{min-width:30px;font-size:12px;font-weight:bold} +.vm-form-input{flex:1} +.vm-form-buttons{display:flex;gap:5px;justify-content:flex-end} +.vm-list::-webkit-scrollbar{width:6px} +.vm-list::-webkit-scrollbar-track{background:var(--SmartThemeBodyColor)} +.vm-list::-webkit-scrollbar-thumb{background:var(--SmartThemeBorderColor);border-radius:3px} +.vm-empty-message{padding:20px;text-align:center;color:#888} +.vm-item-name-visible{opacity:1} +.vm-item-separator{opacity:.3} +.vm-null-value{opacity:.6} +.mes_btn.mes_variables_panel{opacity:.6} +.mes_btn.mes_variables_panel:hover{opacity:1} +.vm-badges{display:inline-flex;gap:6px;margin-left:6px;align-items:center} +.vm-badge[data-type="ro"]{color:#F9C770} +.vm-badge[data-type="struct"]{color:#48B0C7} +.vm-badge[data-type="cons"]{color:#D95E37} +.vm-badge:hover{opacity:1;filter:saturate(1.2)} +:root{--vm-badge-nudge:0.06em} +.vm-item-name{display:inline-flex;align-items:center} +.vm-badges{display:inline-flex;gap:.35em;margin-left:.35em} +.vm-item-name .vm-badge{display:flex;width:1em;position:relative;top:var(--vm-badge-nudge) !important;opacity:.9} +.vm-item-name .vm-badge i{display:block;font-size:.8em;line-height:1em} +`; + +const EMBEDDED_HTML = ` + +`; + +const VT = { + character: { getter: getLocalVariable, setter: setLocalVariable, storage: ()=> chat_metadata?.variables || (chat_metadata.variables = {}), save: saveMetadataDebounced }, + global: { getter: getGlobalVariable, setter: setGlobalVariable, storage: ()=> extension_settings.variables?.global || ((extension_settings.variables = { global: {} }).global), save: saveSettingsDebounced }, +}; + +const LWB_RULES_KEY='LWB_RULES'; +const getRulesTable = () => { try { return getContext()?.chatMetadata?.[LWB_RULES_KEY] || {}; } catch { return {}; } }; +const pathKey = (arr)=>{ try { return (arr||[]).map(String).join('.'); } catch { return ''; } }; +const getRuleNodeByPath = (arr)=> (pathKey(arr) ? (getRulesTable()||{})[pathKey(arr)] : undefined); +const hasAnyRule = (n)=>{ + if(!n) return false; + if(n.ro) return true; + if(n.objectPolicy && n.objectPolicy!=='none') return true; + if(n.arrayPolicy && n.arrayPolicy!=='lock') return true; + const c=n.constraints||{}; + return ('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source); +}; +const ruleTip = (n)=>{ + if(!n) return ''; + const lines=[], c=n.constraints||{}; + if(n.ro) lines.push('只读:$ro'); + if(n.objectPolicy){ const m={none:'(默认:不可增删键)',ext:'$ext(可增键)',prune:'$prune(可删键)',free:'$free(可增删键)'}; lines.push(`对象策略:${m[n.objectPolicy]||n.objectPolicy}`); } + if(n.arrayPolicy){ const m={lock:'(默认:不可增删项)',grow:'$grow(可增项)',shrink:'$shrink(可删项)',list:'$list(可增删项)'}; lines.push(`数组策略:${m[n.arrayPolicy]||n.arrayPolicy}`); } + if('min'in c||'max'in c){ if('min'in c&&'max'in c) lines.push(`范围:$range=[${c.min},${c.max}]`); else if('min'in c) lines.push(`下限:$min=${c.min}`); else lines.push(`上限:$max=${c.max}`); } + if('step'in c) lines.push(`步长:$step=${c.step}`); + if(Array.isArray(c.enum)&&c.enum.length) lines.push(`枚举:$enum={${c.enum.join(';')}}`); + if(c.regex&&c.regex.source) lines.push(`正则:$match=/${c.regex.source}/${c.regex.flags||''}`); + return lines.join('\n'); +}; +const badgesHtml = (n)=>{ + if(!hasAnyRule(n)) return ''; + const tip=ruleTip(n).replace(/"/g,'"'), out=[]; + if(n.ro) out.push(``); + if((n.objectPolicy&&n.objectPolicy!=='none')||(n.arrayPolicy&&n.arrayPolicy!=='lock')) out.push(``); + const c=n.constraints||{}; if(('min'in c)||('max'in c)||('step'in c)||(Array.isArray(c.enum)&&c.enum.length)||(c.regex&&c.regex.source)) out.push(``); + return out.length?`${out.join('')}`:''; +}; +const debounce=(fn,ms=200)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);}}; + +class VariablesPanel { + constructor(){ + this.state={isOpen:false,isEnabled:false,container:null,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''}; + this.variableSnapshot=null; this.eventHandlers={}; this.savingInProgress=false; this.containerHtml=EMBEDDED_HTML; + } + + async init(){ + this.injectUI(); this.bindControlToggle(); + const s=this.getSettings(); this.state.isEnabled=s.enabled; this.syncCheckbox(); + if(s.enabled) this.enable(); + } + + injectUI(){ + if(!document.getElementById('variables-panel-css')){ + const st=document.createElement('style'); st.id='variables-panel-css'; st.textContent=EMBEDDED_CSS; document.head.appendChild(st); + } + } + + getSettings(){ extension_settings.LittleWhiteBox ??= {}; return extension_settings.LittleWhiteBox.variablesPanel ??= {...CONFIG.defaultSettings}; } + vt(t){ return VT[t]; } + store(t){ return this.vt(t).storage(); } + + enable(){ + this.createContainer(); this.bindEvents(); + ['character','global'].forEach(t=>this.normalizeStore(t)); + this.loadVariables(); this.installMessageButtons(); + } + disable(){ this.cleanup(); } + + cleanup(){ + this.stopWatcher(); this.unbindEvents(); this.unbindControlToggle(); this.removeContainer(); this.removeAllMessageButtons(); + const tm=this.state.timers; if(tm.watcher) clearInterval(tm.watcher); if(tm.longPress) clearTimeout(tm.longPress); + tm.touch.forEach(x=>clearTimeout(x)); tm.touch.clear(); + Object.assign(this.state,{isOpen:false,timers:{watcher:null,longPress:null,touch:new Map()},currentInlineForm:null,formState:{},rulesChecksum:''}); + this.variableSnapshot=null; this.savingInProgress=false; + } + + createContainer(){ + if(!this.state.container?.length){ + $('body').append(this.containerHtml); + this.state.container=$("#vm-container"); + $("#vm-close").off('click').on('click',()=>this.close()); + } + } + removeContainer(){ this.state.container?.remove(); this.state.container=null; } + + open(){ + if(!this.state.isEnabled) return toastr.warning('请先启用变量面板'); + this.createContainer(); this.bindEvents(); this.state.isOpen=true; this.state.container.show(); + this.state.rulesChecksum = JSON.stringify(getRulesTable()||{}); + this.loadVariables(); this.startWatcher(); + } + close(){ this.state.isOpen=false; this.stopWatcher(); this.unbindEvents(); this.removeContainer(); } + + bindControlToggle(){ + const id='xiaobaix_variables_panel_enabled'; + const bind=()=>{ + const cb=document.getElementById(id); if(!cb) return false; + this.handleCheckboxChange && cb.removeEventListener('change',this.handleCheckboxChange); + this.handleCheckboxChange=e=> this.toggleEnabled(e.target instanceof HTMLInputElement ? !!e.target.checked : false); + cb.addEventListener('change',this.handleCheckboxChange); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; return true; + }; + if(!bind()) setTimeout(bind,100); + } + unbindControlToggle(){ + const cb=document.getElementById('xiaobaix_variables_panel_enabled'); + if(cb && this.handleCheckboxChange) cb.removeEventListener('change',this.handleCheckboxChange); + this.handleCheckboxChange=null; + } + syncCheckbox(){ const cb=document.getElementById('xiaobaix_variables_panel_enabled'); if(cb instanceof HTMLInputElement) cb.checked=this.state.isEnabled; } + + bindEvents(){ + if(!this.state.container?.length) return; + this.unbindEvents(); + const ns='.vm'; + $(document) + .on(`click${ns}`,'.vm-section [data-act]',e=>this.onHeaderAction(e)) + .on(`touchstart${ns}`,'.vm-item>.vm-item-header',e=>this.handleTouch(e)) + .on(`click${ns}`,'.vm-item>.vm-item-header',e=>this.handleItemClick(e)) + .on(`click${ns}`,'.vm-item-controls [data-act]',e=>this.onItemAction(e)) + .on(`click${ns}`,'.vm-inline-form [data-act]',e=>this.onInlineAction(e)) + .on(`mousedown${ns} touchstart${ns}`,'[data-act="copy"]',e=>this.bindCopyPress(e)); + ['character','global'].forEach(t=> $(`#${t}-vm-search`).on('input',e=>{ + if(e.currentTarget instanceof HTMLInputElement) this.searchVariables(t,e.currentTarget.value); + else this.searchVariables(t,''); + })); + } + unbindEvents(){ $(document).off('.vm'); ['character','global'].forEach(t=> $(`#${t}-vm-search`).off('input')); } + + onHeaderAction(e){ + e.preventDefault(); e.stopPropagation(); + const b=$(e.currentTarget), act=b.data('act'), t=b.data('type'); + ({ + import:()=>this.importVariables(t), + export:()=>this.exportVariables(t), + add:()=>this.showAddForm(t), + collapse:()=>this.collapseAll(t), + 'clear-all':()=>this.clearAllVariables(t), + 'save-add':()=>this.saveAddVariable(t), + 'cancel-add':()=>this.hideAddForm(t), + }[act]||(()=>{}))(); + } + + onItemAction(e){ + e.preventDefault(); e.stopPropagation(); + const btn=$(e.currentTarget), act=btn.data('act'), item=btn.closest('.vm-item'), + t=this.getVariableType(item), path=this.getItemPath(item); + ({ + edit: ()=>this.editAction(item,'edit',t,path), + 'add-child': ()=>this.editAction(item,'addChild',t,path), + delete: ()=>this.handleDelete(item,t,path), + copy: ()=>{} + }[act]||(()=>{}))(); + } + + onInlineAction(e){ e.preventDefault(); e.stopPropagation(); const act=$(e.currentTarget).data('act'); act==='inline-save'? this.handleInlineSave($(e.currentTarget).closest('.vm-inline-form')) : this.hideInlineForm(); } + + bindCopyPress(e){ + e.preventDefault(); e.stopPropagation(); + const start=Date.now(); + this.state.timers.longPress=setTimeout(()=>{ this.handleCopy(e,true); this.state.timers.longPress=null; },CONFIG.longPressDelay); + const release=(re)=>{ + if(this.state.timers.longPress){ + clearTimeout(this.state.timers.longPress); this.state.timers.longPress=null; + if(re.type!=='mouseleave' && (Date.now()-start) this.state.isOpen && this.checkChanges(), CONFIG.watchInterval); } + stopWatcher(){ if(this.state.timers.watcher){ clearInterval(this.state.timers.watcher); this.state.timers.watcher=null; } } + + updateSnapshot(){ this.variableSnapshot={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; } + + expandChangedKeys(changed){ + ['character','global'].forEach(t=>{ + const set=changed[t]; if(!set?.size) return; + setTimeout(()=>{ + const list=$(`#${t}-variables-list .vm-item[data-key]`); + set.forEach(k=> list.filter((_,el)=>$(el).data('key')===k).addClass('expanded')); + },10); + }); + } + + checkChanges(){ + try{ + const sum=JSON.stringify(getRulesTable()||{}); + if(sum!==this.state.rulesChecksum){ + this.state.rulesChecksum=sum; + const keep=this.saveAllExpandedStates(); + this.loadVariables(); this.restoreAllExpandedStates(keep); + } + const cur={ character:this.makeSnapshotMap('character'), global:this.makeSnapshotMap('global') }; + const changed={character:new Set(), global:new Set()}; + ['character','global'].forEach(t=>{ + const prev=this.variableSnapshot?.[t]||{}, now=cur[t]; + new Set([...Object.keys(prev),...Object.keys(now)]).forEach(k=>{ if(!(k in prev)||!(k in now)||prev[k]!==now[k]) changed[t].add(k);}); + }); + if(changed.character.size||changed.global.size){ + const keep=this.saveAllExpandedStates(); + this.variableSnapshot=cur; this.loadVariables(); this.restoreAllExpandedStates(keep); this.expandChangedKeys(changed); + } + }catch{} + } + + loadVariables(){ + ['character','global'].forEach(t=>{ + this.renderVariables(t); + $(`#${t}-variables-section [data-act="collapse"] i`).removeClass('fa-chevron-up').addClass('fa-chevron-down'); + }); + } + + renderVariables(t){ + const c=$(`#${t}-variables-list`).empty(), s=this.store(t), root=Object.entries(s); + if(!root.length) c.append('
暂无变量
'); + else root.forEach(([k,v])=> c.append(this.createVariableItem(t,k,v,0,[k]))); + } + + createVariableItem(t,k,v,l=0,fullPath=[]){ + const parsed=this.parseValue(v), hasChildren=typeof parsed==='object' && parsed!==null; + const disp = l===0? this.formatTopLevelValue(v) : this.formatValue(v); + const ruleNode=getRuleNodeByPath(fullPath); + return $(`
0?`data-level="${l}"`:''} data-path="${this.escape(pathKey(fullPath))}"> +
+
${this.escape(k)}${badgesHtml(ruleNode)}:
+
${disp}
+
${this.createButtons()}
+
+ ${hasChildren?`
${this.renderChildren(parsed,l+1,fullPath)}
`:''} +
`); + } + + createButtons(){ + return [ + ['edit','fa-edit','编辑'], + ['add-child','fa-plus-circle','添加子变量'], + ['copy','fa-eye-dropper','复制(长按: 宏,单击: 变量路径)'], + ['delete','fa-trash','删除'], + ].map(([act,ic,ti])=>``).join(''); + } + + createInlineForm(t,target,fs){ + const fid=`inline-form-${Date.now()}`; + const inf=$(` +
+
+
+
+ + +
+
`); + this.state.currentInlineForm?.remove(); + target.after(inf); this.state.currentInlineForm=inf; this.state.formState={...fs,formId:fid,targetItem:target}; + const ta=inf.find('.inline-value'); ta.on('input',()=>this.autoResizeTextarea(ta)); + setTimeout(()=>{ inf.addClass('active'); inf.find('.inline-name').focus(); },10); + return inf; + } + + renderChildren(obj,level,parentPath){ return Object.entries(obj).map(([k,v])=> this.createVariableItem(null,k,v,level,[...(parentPath||[]),k])[0].outerHTML).join(''); } + + handleTouch(e){ + if($(e.target).closest('.vm-item-controls').length) return; + e.stopPropagation(); + const item=$(e.currentTarget).closest('.vm-item'); $('.vm-item').removeClass('touched'); item.addClass('touched'); + this.clearTouchTimer(item); + const t=setTimeout(()=>{ item.removeClass('touched'); this.state.timers.touch.delete(item[0]); },CONFIG.touchTimeout); + this.state.timers.touch.set(item[0],t); + } + clearTouchTimer(i){ const t=this.state.timers.touch.get(i[0]); if(t){ clearTimeout(t); this.state.timers.touch.delete(i[0]); } } + + handleItemClick(e){ + if($(e.target).closest('.vm-item-controls').length) return; + e.stopPropagation(); + $(e.currentTarget).closest('.vm-item').toggleClass('expanded'); + } + + async writeClipboard(txt){ + try{ + if(navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(txt); + else { const ta=document.createElement('textarea'); Object.assign(ta.style,{position:'fixed',top:0,left:0,width:'2em',height:'2em',padding:0,border:'none',outline:'none',boxShadow:'none',background:'transparent'}); ta.value=txt; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } + return true; + }catch{ return false; } + } + + handleCopy(e,longPress){ + const item=$(e.target).closest('.vm-item'), path=this.getItemPath(item), t=this.getVariableType(item), level=parseInt(item.attr('data-level'))||0; + const formatted=this.formatPath(t,path); let cmd=''; + if(longPress){ + if(t==='character'){ + cmd = level===0 ? `{{getvar::${path[0]}}}` : `{{xbgetvar::${formatted}}}`; + }else{ + cmd = `{{getglobalvar::${path[0]}}}`; + if(level>0) toastr.info('全局变量宏暂不支持子路径,已复制顶级变量'); + } + }else cmd=formatted; + (async()=> (await this.writeClipboard(cmd)) ? toastr.success(`已复制: ${cmd}`) : toastr.error('复制失败'))(); + } + + editAction(item,action,type,path){ + const inf=this.createInlineForm(type,item,{action,path,type}); + if(action==='edit'){ + const v=this.getValueByPath(type,path); + setTimeout(()=>{ + inf.find('.inline-name').val(path[path.length-1]); + const ta=inf.find('.inline-value'); + const fill=(val)=> Array.isArray(val)? (val.length===1 ? String(val[0]??'') : JSON.stringify(val,null,2)) : (val&&typeof val==='object'? JSON.stringify(val,null,2) : String(val??'')); + ta.val(fill(v)); this.autoResizeTextarea(ta); + },50); + }else if(action==='addChild'){ + inf.find('.inline-name').attr('placeholder',`为 "${path.join('.')}" 添加子变量名称`); + inf.find('.inline-value').attr('placeholder','子变量值 (支持JSON格式)'); + } + } + + handleDelete(_item,t,path){ + const n=path[path.length-1]; + if(!confirm(`确定要删除 "${n}" 吗?`)) return; + this.withGlobalRefresh(()=> this.deleteByPathSilently(t,path)); + toastr.success('变量已删除'); + } + + refreshAndKeep(t,states){ this.vt(t).save(); this.loadVariables(); this.updateSnapshot(); states && this.restoreExpandedStates(t,states); } + withPreservedExpansion(t,fn){ const s=this.saveExpandedStates(t); fn(); this.refreshAndKeep(t,s); } + withGlobalRefresh(fn){ const s=this.saveAllExpandedStates(); fn(); this.loadVariables(); this.updateSnapshot(); this.restoreAllExpandedStates(s); } + + handleInlineSave(form){ + if(this.savingInProgress) return; this.savingInProgress=true; + try{ + if(!form?.length) return toastr.error('表单未找到'); + const rawName=form.find('.inline-name').val(); + const rawValue=form.find('.inline-value').val(); + const name= typeof rawName==='string'? rawName.trim() : String(rawName ?? '').trim(); + const value= typeof rawValue==='string'? rawValue.trim() : String(rawValue ?? '').trim(); + const type=form.data('type'); + if(!name) return form.find('.inline-name').focus(), toastr.error('请输入变量名称'); + const val=this.processValue(value), {action,path}=this.state.formState; + this.withPreservedExpansion(type,()=>{ + if(action==='addChild') { + this.setValueByPath(type,[...path,name],val); + } else if(action==='edit'){ + const old=path[path.length-1]; + if(name!==old){ + this.deleteByPathSilently(type,path); + if(path.length===1) { + const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; + this.vt(type).setter(name,toSave); + } else { + this.setValueByPath(type,[...path.slice(0,-1),name],val); + } + } else { + this.setValueByPath(type,path,val); + } + } else { + const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; + this.vt(type).setter(name,toSave); + } + }); + this.hideInlineForm(); toastr.success('变量已保存'); + }catch(e){ toastr.error('JSON格式错误: '+e.message); } + finally{ this.savingInProgress=false; } + } + hideInlineForm(){ if(this.state.currentInlineForm){ this.state.currentInlineForm.removeClass('active'); setTimeout(()=>{ this.state.currentInlineForm?.remove(); this.state.currentInlineForm=null; },200);} this.state.formState={}; } + + showAddForm(t){ + this.hideInlineForm(); + const f=$(`#${t}-vm-add-form`).addClass('active'), ta=$(`#${t}-vm-value`); + $(`#${t}-vm-name`).val('').attr('placeholder','变量名称').focus(); + ta.val('').attr('placeholder','变量值 (支持JSON格式)'); + if(!ta.data('auto-resize-bound')){ ta.on('input',()=>this.autoResizeTextarea(ta)); ta.data('auto-resize-bound',true); } + } + hideAddForm(t){ $(`#${t}-vm-add-form`).removeClass('active'); $(`#${t}-vm-name, #${t}-vm-value`).val(''); this.state.formState={}; } + + saveAddVariable(t){ + if(this.savingInProgress) return; this.savingInProgress=true; + try{ + const rawN=$(`#${t}-vm-name`).val(); + const rawV=$(`#${t}-vm-value`).val(); + const n= typeof rawN==='string' ? rawN.trim() : String(rawN ?? '').trim(); + const v= typeof rawV==='string' ? rawV.trim() : String(rawV ?? '').trim(); + if(!n) return toastr.error('请输入变量名称'); + const val=this.processValue(v); + this.withPreservedExpansion(t,()=> { + const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; + this.vt(t).setter(n,toSave); + }); + this.hideAddForm(t); toastr.success('变量已保存'); + }catch(e){ toastr.error('JSON格式错误: '+e.message); } + finally{ this.savingInProgress=false; } + } + + getValueByPath(t,p){ if(p.length===1) return this.vt(t).getter(p[0]); let v=this.parseValue(this.vt(t).getter(p[0])); p.slice(1).forEach(k=> v=v?.[k]); return v; } + + setValueByPath(t,p,v){ + if(p.length===1){ + const toSave = (typeof v==='object' && v!==null) ? JSON.stringify(v) : v; + this.vt(t).setter(p[0], toSave); + return; + } + let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) root={}; + let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; }); + cur[p[p.length-1]]=v; this.vt(t).setter(p[0], JSON.stringify(root)); + } + + deleteByPathSilently(t,p){ + if(p.length===1){ delete this.store(t)[p[0]]; return; } + let root=this.parseValue(this.vt(t).getter(p[0])); if(typeof root!=='object'||root===null) return; + let cur=root; p.slice(1,-1).forEach(k=>{ if(typeof cur[k]!=='object'||cur[k]===null) cur[k]={}; cur=cur[k]; }); + delete cur[p[p.length-1]]; this.vt(t).setter(p[0], JSON.stringify(root)); + } + + formatPath(t,path){ + if(!Array.isArray(path)||!path.length) return ''; + let out=String(path[0]), cur=this.parseValue(this.vt(t).getter(path[0])); + for(let i=1;i[${c} items]`; } return this.formatValue(p); } + formatValue(v){ if(v==null) return `${v}`; const e=this.escape(String(v)); return `${e.length>50? e.substring(0,50)+'...' : e}`; } + escape(t){ const d=document.createElement('div'); d.textContent=t; return d.innerHTML; } + autoResizeTextarea(ta){ if(!ta?.length) return; const el=ta[0]; el.style.height='auto'; const sh=el.scrollHeight, max=Math.min(300,window.innerHeight*0.4), fh=Math.max(60,Math.min(max,sh+4)); el.style.height=fh+'px'; el.style.overflowY=sh>max-4?'auto':'hidden'; } + searchVariables(t,q){ const l=q.toLowerCase().trim(); $(`#${t}-variables-list .vm-item`).each(function(){ $(this).toggle(!l || $(this).text().toLowerCase().includes(l)); }); } + collapseAll(t){ const items=$(`#${t}-variables-list .vm-item`), icon=$(`#${t}-variables-section [data-act="collapse"] i`); const any=items.filter('.expanded').length>0; items.toggleClass('expanded',!any); icon.toggleClass('fa-chevron-up',!any).toggleClass('fa-chevron-down',any); } + + clearAllVariables(t){ + if(!confirm(`确定要清除所有${t==='character'?'角色':'全局'}变量吗?`)) return; + this.withPreservedExpansion(t,()=>{ const s=this.store(t); Object.keys(s).forEach(k=> delete s[k]); }); + toastr.success('变量已清除'); + } + + async importVariables(t){ + const inp=document.createElement('input'); inp.type='file'; inp.accept='.json'; + inp.onchange=async(e)=>{ + try{ + const tgt=e.target; + const file = (tgt && 'files' in tgt && tgt.files && tgt.files[0]) ? tgt.files[0] : null; + if(!file) throw new Error('未选择文件'); + const txt=await file.text(), v=JSON.parse(txt); + this.withPreservedExpansion(t,()=> { + Object.entries(v).forEach(([k,val])=> { + const toSave=(typeof val==='object'&&val!==null)?JSON.stringify(val):val; + this.vt(t).setter(k,toSave); + }); + }); + toastr.success(`成功导入 ${Object.keys(v).length} 个变量`); + }catch{ toastr.error('文件格式错误'); } + }; + inp.click(); + } + + exportVariables(t){ + const v=this.store(t), b=new Blob([JSON.stringify(v,null,2)],{type:'application/json'}), a=document.createElement('a'); + a.href=URL.createObjectURL(b); a.download=`${t}-variables-${new Date().toISOString().split('T')[0]}.json`; a.click(); + toastr.success('变量已导出'); + } + + saveExpandedStates(t){ const s=new Set(); $(`#${t}-variables-list .vm-item.expanded`).each(function(){ const k=$(this).data('key'); if(k!==undefined) s.add(String(k)); }); return s; } + saveAllExpandedStates(){ return { character:this.saveExpandedStates('character'), global:this.saveExpandedStates('global') }; } + restoreExpandedStates(t,s){ if(!s?.size) return; setTimeout(()=>{ $(`#${t}-variables-list .vm-item`).each(function(){ const k=$(this).data('key'); if(k!==undefined && s.has(String(k))) $(this).addClass('expanded'); }); },50); } + restoreAllExpandedStates(st){ Object.entries(st).forEach(([t,s])=> this.restoreExpandedStates(t,s)); } + + toggleEnabled(en){ + const s=this.getSettings(); s.enabled=this.state.isEnabled=en; saveSettingsDebounced(); this.syncCheckbox(); + en ? (this.enable(),this.open()) : this.disable(); + } + + createPerMessageBtn(messageId){ + const btn=document.createElement('div'); + btn.className='mes_btn mes_variables_panel'; + btn.title='变量面板'; + btn.dataset.mid=messageId; + btn.innerHTML=''; + btn.addEventListener('click',(e)=>{ e.preventDefault(); e.stopPropagation(); this.open(); }); + return btn; + } + + addButtonToMessage(messageId){ + const msg=$(`#chat .mes[mesid="${messageId}"]`); + if(!msg.length || msg.find('.mes_btn.mes_variables_panel').length) return; + const btn=this.createPerMessageBtn(messageId); + const appendToFlex=(m)=>{ const flex=m.find('.flex-container.flex1.alignitemscenter'); if(flex.length) flex.append(btn); }; + if(typeof window['registerButtonToSubContainer']==='function'){ + const ok=window['registerButtonToSubContainer'](messageId,btn); + if(!ok) appendToFlex(msg); + } else appendToFlex(msg); + } + + addButtonsToAllMessages(){ $('#chat .mes').each((_,el)=>{ const mid=el.getAttribute('mesid'); if(mid) this.addButtonToMessage(mid); }); } + removeAllMessageButtons(){ $('#chat .mes .mes_btn.mes_variables_panel').remove(); } + + installMessageButtons(){ + const delayedAdd=(id)=> setTimeout(()=>{ if(id!=null) this.addButtonToMessage(id); },120); + const delayedScan=()=> setTimeout(()=> this.addButtonsToAllMessages(),150); + this.removeMessageButtonsListeners(); + const idFrom=(d)=> typeof d==='object' ? (d.messageId||d.id) : d; + + if (!this.msgEvents) this.msgEvents = createModuleEvents('variablesPanel:messages'); + + this.msgEvents.onMany([ + event_types.USER_MESSAGE_RENDERED, + event_types.CHARACTER_MESSAGE_RENDERED, + event_types.MESSAGE_RECEIVED, + event_types.MESSAGE_UPDATED, + event_types.MESSAGE_SWIPED, + event_types.MESSAGE_EDITED + ].filter(Boolean), (d) => delayedAdd(idFrom(d))); + + this.msgEvents.on(event_types.MESSAGE_SENT, debounce(() => delayedScan(), 300)); + this.msgEvents.on(event_types.CHAT_CHANGED, () => delayedScan()); + + this.addButtonsToAllMessages(); + } + + removeMessageButtonsListeners(){ + if (this.msgEvents) { + this.msgEvents.cleanup(); + } + } + + removeMessageButtons(){ this.removeMessageButtonsListeners(); this.removeAllMessageButtons(); } + + normalizeStore(t){ + const s=this.store(t); let changed=0; + for(const[k,v] of Object.entries(s)){ + if(typeof v==='object' && v!==null){ + try{ s[k]=JSON.stringify(v); changed++; }catch{} + } + } + if(changed) this.vt(t).save?.(); + } +} + +let variablesPanelInstance=null; + +export async function initVariablesPanel(){ + try{ + extension_settings.variables ??= { global:{} }; + if(variablesPanelInstance) variablesPanelInstance.cleanup(); + variablesPanelInstance=new VariablesPanel(); + await variablesPanelInstance.init(); + return variablesPanelInstance; + }catch(e){ + console.error(`[${CONFIG.extensionName}] 加载失败:`,e); + toastr?.error?.('Variables Panel加载失败'); + throw e; + } +} + +export function getVariablesPanelInstance(){ return variablesPanelInstance; } +export function cleanupVariablesPanel(){ if(variablesPanelInstance){ variablesPanelInstance.removeMessageButtons(); variablesPanelInstance.cleanup(); variablesPanelInstance=null; } } diff --git a/modules/wallhaven-background.js b/modules/wallhaven-background.js index 1fd5583..2cc43f0 100644 --- a/modules/wallhaven-background.js +++ b/modules/wallhaven-background.js @@ -1,2182 +1,2182 @@ -import { extension_settings, getContext } from "../../../../extensions.js"; -import { saveSettingsDebounced } from "../../../../../script.js"; -import { EXT_ID } from "../core/constants.js"; -import { createModuleEvents, event_types } from "../core/event-manager.js"; - -const MODULE_NAME = "wallhavenBackground"; -const messageEvents = createModuleEvents('wallhaven:messages'); -const globalEvents = createModuleEvents('wallhaven'); - -const defaultSettings = { - enabled: false, - bgMode: false, - category: "010", - purity: "100", - opacity: 0.3, - customTags: [] -}; - -const tagWeights = { - custom: 3.0, - characters: 3, - locations: 3, - nsfw_actions: 3, - intimate_settings: 3, - poses: 2, - clothing: 2, - nsfw_body_parts: 2, - activities: 2, - expressions: 1.5, - body_features: 1.5, - nsfw_states: 1.5, - fetish_categories: 1.5, - clothing_states: 1.5, - nsfw_descriptions: 1.5, - colors: 1, - objects: 1, - weather_time: 1, - styles: 1, - emotional_states: 1, - romance_keywords: 1, - body_modifications: 1, - nsfw_sounds: 1 -}; - -const wallhavenTags = { - characters: { - // 基础人称 - "女孩": "anime girl", "少女": "anime girl", "女性": "woman", "女人": "woman", - "男孩": "boy", "少年": "boy", "男性": "man", "男人": "man", - "美女": "beautiful woman", "帅哥": "handsome man", "女生": "girl", "男生": "boy", - - // 职业角色 - "女仆": "maid", "侍女": "maid", "佣人": "maid", "管家": "butler", - "秘书": "secretary", "助理": "assistant", "下属": "subordinate", - "老板": "boss", "上司": "superior", "领导": "leader", "经理": "manager", - "同事": "colleague", "伙伴": "partner", "搭档": "partner", - "客户": "client", "顾客": "customer", "委托人": "client", - - // 学校相关 - "学生": "student", "学员": "student", "同学": "student", - "男同学": "male student", "女同学": "schoolgirl", "女学生": "schoolgirl", "男学生": "male student", - "老师": "teacher", "教师": "teacher", "先生": "teacher", "导师": "mentor", - "校长": "principal", "教授": "professor", "讲师": "lecturer", - "学姐": "senior student", "学妹": "junior student", "学长": "senior student", "学弟": "junior student", - "班长": "class president", "社长": "club president", - - // 医疗相关 - "护士": "nurse", "白衣天使": "nurse", "医护": "nurse", - "医生": "doctor", "大夫": "doctor", "医师": "physician", - "病人": "patient", "患者": "patient", - - // 家庭关系 - "母亲": "mother", "妈妈": "mother", "母": "mother", "妈": "mother", - "父亲": "father", "爸爸": "father", "父": "father", "爸": "father", - "姐姐": "sister", "妹妹": "sister", "哥哥": "brother", "弟弟": "brother", - "女儿": "daughter", "闺女": "daughter", "儿子": "son", - "妻子": "wife", "老婆": "wife", "丈夫": "husband", "老公": "husband", - "岳母": "mother-in-law", "婆婆": "mother-in-law", "丈母娘": "mother-in-law", - "阿姨": "aunt", "叔叔": "uncle", "表姐": "cousin", "表妹": "cousin", - "邻居": "neighbor", "房东": "landlord", "租客": "tenant", - - // 特殊身份 - "公主": "princess", "殿下": "princess", "王女": "princess", - "王子": "prince", "王子殿下": "prince", "皇子": "prince", - "女王": "queen", "国王": "king", "皇帝": "emperor", "皇后": "empress", - "贵族": "noble", "富家千金": "rich girl", "大小姐": "young lady", - "平民": "commoner", "村民": "villager", "市民": "citizen", - - // 二次元角色 - "猫娘": "catgirl", "猫女": "catgirl", "猫咪女孩": "catgirl", - "狐娘": "fox girl", "狐狸女孩": "fox girl", "狐仙": "fox girl", - "兔娘": "bunny girl", "兔女郎": "bunny girl", "兔子女孩": "bunny girl", - "狼娘": "wolf girl", "犬娘": "dog girl", "龙娘": "dragon girl", - "魔女": "witch", "女巫": "witch", "巫女": "witch", "魔法少女": "magical girl", - "天使": "angel", "小天使": "angel", "堕天使": "fallen angel", - "恶魔": "demon", "魅魔": "demon", "小恶魔": "demon", - "精灵": "elf", "森林精灵": "elf", "暗精灵": "dark elf", - "吸血鬼": "vampire", "血族": "vampire", "僵尸": "zombie", - "人偶": "doll", "机器人": "android", "人造人": "artificial human", - "外星人": "alien", "异世界人": "otherworld person", - - // 职业类型 - "忍者": "ninja", "女忍": "ninja", "武士": "warrior", "剑士": "swordsman", - "骑士": "knight", "圣骑士": "paladin", "战士": "warrior", - "法师": "wizard", "魔法师": "wizard", "术士": "sorcerer", - "牧师": "priest", "修女": "nun", "尼姑": "nun", - "盗贼": "thief", "刺客": "assassin", "间谍": "spy", - "雇佣兵": "mercenary", "佣兵": "mercenary", "赏金猎人": "bounty hunter", - - // 现代职业 - "警察": "police", "警官": "police", "侦探": "detective", "探长": "detective", - "消防员": "firefighter", "消防": "firefighter", - "军人": "soldier", "士兵": "soldier", "特种兵": "special forces", - "飞行员": "pilot", "船长": "captain", "司机": "driver", - "厨师": "chef", "料理师": "chef", "服务员": "waitress", - "调酒师": "bartender", "咖啡师": "barista", - "艺术家": "artist", "画家": "artist", "雕塑家": "sculptor", - "音乐家": "musician", "歌手": "singer", "偶像": "idol", - "演员": "actress", "模特": "model", "舞者": "dancer", - "作家": "writer", "记者": "journalist", "编辑": "editor", - "科学家": "scientist", "研究员": "scientist", "博士": "doctor", - "程序员": "programmer", "工程师": "engineer", "设计师": "designer", - "商人": "businessman", "企业家": "entrepreneur", "投资者": "investor", - - // 特殊关系 - "新娘": "bride", "新嫁娘": "bride", "新郎": "groom", - "前女友": "ex-girlfriend", "前男友": "ex-boyfriend", - "青梅竹马": "childhood friend", "闺蜜": "best friend", "好友": "friend", - "对手": "rival", "敌人": "enemy", "仇人": "enemy", - "陌生人": "stranger", "路人": "passerby", "访客": "visitor" - }, - - clothing: { - // 裙装类 - "连衣裙": "dress", "裙子": "dress", "长裙": "long dress", "短裙": "short dress", - "迷你裙": "mini dress", "中裙": "midi dress", "蓬蓬裙": "puffy dress", - "紧身裙": "tight dress", "A字裙": "a-line dress", "包臀裙": "pencil skirt", - "百褶裙": "pleated skirt", "伞裙": "circle skirt", "吊带裙": "slip dress", - - // 制服类 - "校服": "school uniform", "制服": "uniform", "学生服": "school uniform", - "女仆装": "maid outfit", "女仆服": "maid outfit", - "护士服": "nurse outfit", "白大褂": "nurse outfit", - "警服": "police uniform", "军装": "military uniform", - "空姐服": "flight attendant uniform", "服务员服": "waitress uniform", - "OL装": "office lady outfit", "职业装": "business attire", - - // 传统服装 - "和服": "kimono", "浴衣": "kimono", "振袖": "kimono", - "旗袍": "qipao", "中式服装": "chinese dress", "汉服": "hanfu", - "洛丽塔": "lolita dress", "哥特装": "gothic dress", - - // 特殊场合 - "婚纱": "wedding dress", "新娘装": "wedding dress", "礼服": "evening gown", - "晚礼服": "evening dress", "舞会裙": "ball gown", "宴会服": "party dress", - "演出服": "performance outfit", "舞台装": "stage outfit", - - // 休闲装 - "T恤": "t-shirt", "衬衫": "shirt", "衬衣": "blouse", - "吊带": "tank top", "背心": "vest", "卫衣": "hoodie", - "夹克": "jacket", "外套": "coat", "风衣": "trench coat", - "毛衣": "sweater", "针织衫": "knit sweater", "开衫": "cardigan", - - // 裤装 - "牛仔裤": "jeans", "裤子": "pants", "短裤": "shorts", - "热裤": "hot pants", "紧身裤": "leggings", "喇叭裤": "flare pants", - "西装裤": "suit pants", "休闲裤": "casual pants", - - // 运动装 - "运动服": "sportswear", "瑜伽服": "yoga outfit", "健身服": "gym wear", - "田径服": "track suit", "篮球服": "basketball uniform", - - // 睡衣家居 - "睡衣": "pajamas", "居家服": "pajamas", "睡袍": "nightgown", - "浴袍": "bathrobe", "晨袍": "morning robe", "家居服": "loungewear", - - // 泳装 - "泳装": "swimsuit", "泳衣": "swimsuit", "比基尼": "bikini", - "连体泳衣": "one-piece swimsuit", "三点式": "bikini", - - // 内衣 - "内衣": "lingerie", "胸罩": "bra", "内裤": "panties", - "文胸": "bra", "胖次": "panties", "三角裤": "briefs", - "平角裤": "boxers", "内衣套装": "lingerie set", - "情趣内衣": "sexy lingerie", "蕾丝内衣": "lace lingerie", - - // 袜类 - "丝袜": "stockings", "长筒袜": "stockings", "连裤袜": "pantyhose", - "过膝袜": "thigh highs", "短袜": "ankle socks", "船袜": "no-show socks", - "网袜": "fishnet stockings", "白丝": "white stockings", "黑丝": "black stockings", - - // 鞋类 - "高跟鞋": "high heels", "靴子": "boots", "凉鞋": "sandals", - "平底鞋": "flats", "帆布鞋": "canvas shoes", "运动鞋": "sneakers", - "马丁靴": "combat boots", "长靴": "knee boots", "短靴": "ankle boots", - "拖鞋": "slippers", "人字拖": "flip flops", - - // 配饰 - "手套": "gloves", "帽子": "hat", "眼镜": "glasses", - "太阳镜": "sunglasses", "发带": "headband", "头巾": "headscarf", - "围巾": "scarf", "披肩": "shawl", "领带": "tie", - "蝴蝶结": "bow tie", "腰带": "belt", "围裙": "apron", - - // 首饰 - "项链": "necklace", "耳环": "earrings", "戒指": "ring", - "手镯": "bracelet", "脚链": "anklet", "发饰": "hair accessory", - "胸针": "brooch", "手表": "watch" - }, - - body_features: { - // 发型 - "长发": "long hair", "短发": "short hair", "中发": "medium hair", - "马尾": "ponytail", "双马尾": "twintails", "侧马尾": "side ponytail", - "丸子头": "bun", "包子头": "bun", "公主头": "half updo", - "刘海": "bangs", "齐刘海": "straight bangs", "斜刘海": "side bangs", - "卷发": "curly hair", "直发": "straight hair", "波浪发": "wavy hair", - "盘发": "updo", "编发": "braided hair", "麻花辫": "braids", - "单边辫": "side braid", "双辫": "twin braids", - "蓬松发": "voluminous hair", "顺滑发": "silky hair", - - // 发色 - "黑发": "black hair", "金发": "blonde hair", "棕发": "brown hair", - "白发": "white hair", "银发": "silver hair", "红发": "red hair", - "蓝发": "blue hair", "粉发": "pink hair", "紫发": "purple hair", - "绿发": "green hair", "橙发": "orange hair", "灰发": "gray hair", - "彩虹发": "rainbow hair", "渐变发": "gradient hair", - - // 身材 - "高个": "tall", "矮个": "short", "娇小": "petite", "高挑": "tall and slender", - "苗条": "slim", "纤细": "slim", "瘦": "thin", "骨感": "skinny", - "丰满": "curvy", "饱满": "voluptuous", "肉感": "plump", - "匀称": "well-proportioned", "性感": "sexy", "优美": "graceful", - - // 胸部 - "大胸": "large breasts", "巨乳": "huge breasts", "丰满": "large breasts", - "小胸": "small breasts", "贫乳": "small breasts", "平胸": "flat chest", - "挺拔": "perky", "坚挺": "firm", "饱满": "full", - "柔软": "soft", "弹性": "bouncy", "深沟": "cleavage", - - // 腿部 - "美腿": "beautiful legs", "长腿": "long legs", "细腿": "slender legs", - "修长": "slender", "笔直": "straight", "匀称": "shapely", - "大腿": "thighs", "小腿": "calves", "脚踝": "ankles", - - // 皮肤 - "白皙": "fair", "雪白": "snow white", "透白": "translucent", - "古铜": "tanned", "小麦色": "wheat colored", "健康": "healthy", - "光滑": "smooth", "细腻": "delicate", "粗糙": "rough", - "红润": "rosy", "苍白": "pale", "有光泽": "glowing", - - // 眼睛 - "大眼": "big eyes", "小眼": "small eyes", "圆眼": "round eyes", - "细长眼": "narrow eyes", "杏眼": "almond eyes", "桃花眼": "peach blossom eyes", - "双眼皮": "double eyelids", "单眼皮": "single eyelids", - "长睫毛": "long eyelashes", "浓睫毛": "thick eyelashes", - - // 特殊特征 - "猫耳": "cat ears", "狐耳": "fox ears", "兔耳": "bunny ears", - "狼耳": "wolf ears", "犬耳": "dog ears", "精灵耳": "elf ears", - "翅膀": "wings", "天使翅膀": "angel wings", "恶魔翅膀": "demon wings", - "尾巴": "tail", "猫尾": "cat tail", "狐尾": "fox tail", - "角": "horns", "恶魔角": "demon horns", "独角": "unicorn horn", - - // 体格 - "肌肉": "muscular", "强壮": "muscular", "健美": "athletic", - "结实": "sturdy", "精瘦": "lean", "厚实": "solid", - "柔弱": "delicate", "纤弱": "fragile", "娇弱": "frail", - - // 面部特征 - "圆脸": "round face", "瓜子脸": "oval face", "方脸": "square face", - "鹅蛋脸": "oval face", "心形脸": "heart-shaped face", - "高鼻梁": "high nose bridge", "小鼻子": "small nose", - "厚嘴唇": "thick lips", "薄嘴唇": "thin lips", "樱桃嘴": "cherry lips", - "尖下巴": "pointed chin", "圆下巴": "round chin", - "酒窝": "dimples", "笑容": "smile", "梨涡": "dimples", - - // 其他 - "胡子": "beard", "胡须": "mustache", "络腮胡": "full beard", - "光头": "bald", "秃头": "bald", "寸头": "buzz cut", - "疤痕": "scar", "纹身": "tattoo", "胎记": "birthmark", - "雀斑": "freckles", "痣": "mole", "美人痣": "beauty mark", - "虎牙": "fangs", "小虎牙": "small fangs" - }, - - expressions: { - // 快乐情绪 - "微笑": "smile", "笑": "smile", "开心": "happy", "高兴": "happy", - "大笑": "laughing", "窃笑": "giggling", "傻笑": "silly smile", - "甜笑": "sweet smile", "温和笑": "gentle smile", "灿烂笑": "bright smile", - "兴奋": "excited", "激动": "excited", "愉快": "cheerful", - "欣喜": "delighted", "狂喜": "ecstatic", "满意": "satisfied", - - // 悲伤情绪 - "伤心": "sad", "难过": "sad", "哭": "crying", "流泪": "tears", - "大哭": "sobbing", "抽泣": "sniffling", "眼泪汪汪": "teary eyes", - "悲伤": "sorrowful", "沮丧": "depressed", "失落": "disappointed", - "绝望": "despair", "痛苦": "painful", "心碎": "heartbroken", - - // 愤怒情绪 - "生气": "angry", "愤怒": "angry", "恼火": "angry", - "暴怒": "furious", "发火": "mad", "气愤": "indignant", - "不满": "dissatisfied", "抱怨": "complaining", "怨恨": "resentful", - - // 害羞情绪 - "害羞": "shy", "脸红": "blushing", "羞涩": "shy", - "害臊": "bashful", "腼腆": "timid", "不好意思": "embarrassed", - "羞耻": "ashamed", "窘迫": "flustered", "局促": "awkward", - - // 惊讶情绪 - "惊讶": "surprised", "吃惊": "surprised", "震惊": "shocked", - "惊愕": "astonished", "惊恐": "horrified", "目瞪口呆": "stunned", - "困惑": "confused", "疑惑": "puzzled", "迷茫": "bewildered", - - // 温柔情绪 - "温柔": "gentle", "柔和": "gentle", "亲切": "gentle", - "慈祥": "kind", "和善": "friendly", "温暖": "warm", - "关爱": "caring", "怜爱": "tender", "宠溺": "doting", - - // 严肃情绪 - "严肃": "serious", "认真": "serious", "冷静": "calm", - "严厉": "stern", "冷漠": "indifferent", "无表情": "expressionless", - "冷酷": "cold", "淡漠": "aloof", "疏远": "distant", - - // 疲倦情绪 - "困": "sleepy", "累": "tired", "疲倦": "tired", - "疲惫": "exhausted", "困倦": "drowsy", "无精打采": "listless", - "虚弱": "weak", "萎靡": "dispirited", "懒散": "lazy", - - // 其他情绪 - "紧张": "nervous", "担心": "worried", "焦虑": "anxious", - "恐惧": "fearful", "害怕": "scared", "胆怯": "timid", - "自信": "confident", "骄傲": "proud", "得意": "smug", - "傲慢": "arrogant", "轻蔑": "contemptuous", "不屑": "disdainful", - "好奇": "curious", "感兴趣": "interested", "专注": "focused", - "集中": "concentrated", "沉思": "contemplating", "思考": "thinking", - "无聊": "bored", "厌倦": "tired of", "烦躁": "irritated", - "期待": "expectant", "渴望": "longing", "向往": "yearning" - }, - - poses: { - // 基础姿势 - "站着": "standing", "站立": "standing", "直立": "upright", - "坐着": "sitting", "坐下": "sitting", "端坐": "sitting properly", - "躺着": "lying", "躺下": "lying", "平躺": "lying flat", - "跪着": "kneeling", "下跪": "kneeling", "跪坐": "seiza", - "蹲着": "squatting", "蹲下": "crouching", "半蹲": "half squat", - - // 动作姿势 - "走路": "walking", "行走": "walking", "漫步": "strolling", - "跑步": "running", "奔跑": "running", "疾跑": "sprinting", - "跳跃": "jumping", "蹦跳": "hopping", "飞跃": "leaping", - "跳舞": "dancing", "舞蹈": "dancing", "旋转": "spinning", - "伸展": "stretching", "弯腰": "bending", "下腰": "backbend", - - // 手部动作 - "举手": "arms up", "抬手": "arms up", "双手举起": "both arms up", - "伸手": "reaching out", "指向": "pointing", "挥手": "waving", - "鼓掌": "clapping", "握拳": "clenched fist", "比心": "heart gesture", - "捂脸": "covering face", "托腮": "chin rest", "撑头": "head rest", - - // 互动姿势 - "拥抱": "hugging", "抱着": "hugging", "搂抱": "embracing", - "牵手": "holding hands", "握手": "handshake", "搀扶": "supporting", - "背着": "carrying on back", "抱起": "lifting up", "搂腰": "arm around waist", - - // 生活姿势 - "睡觉": "sleeping", "熟睡": "sleeping", "打盹": "napping", - "看书": "reading", "阅读": "reading", "翻书": "turning pages", - "写字": "writing", "书写": "writing", "画画": "drawing", - "工作": "working", "学习": "studying", "思考": "thinking", - "做作业": "homework", "写作业": "homework", "考试": "taking exam", - - // 运动姿势 - "游泳": "swimming", "潜水": "diving", "跳水": "diving", - "爬山": "climbing", "攀登": "climbing", "攀岩": "rock climbing", - "骑车": "cycling", "开车": "driving", "骑马": "horseback riding", - "滑雪": "skiing", "滑冰": "ice skating", "溜冰": "skating", - "健身": "exercising", "瑜伽": "yoga", "拉伸": "stretching", - - // 战斗姿势 - "战斗": "fighting", "格斗": "combat", "对战": "battle", - "攻击": "attacking", "防御": "defending", "出拳": "punching", - "踢腿": "kicking", "挥剑": "sword swinging", "射箭": "archery", - - // 表演姿势 - "唱歌": "singing", "演奏": "playing instrument", "表演": "performing", - "朗诵": "reciting", "演讲": "giving speech", "主持": "hosting", - - // 特殊姿势 - "冥想": "meditation", "祈祷": "praying", "许愿": "making wish", - "仰望": "looking up", "俯视": "looking down", "回首": "looking back", - "侧身": "side view", "背影": "back view", "正面": "front view", - "倚靠": "leaning", "靠墙": "leaning against wall", "趴着": "lying on stomach" - }, - - locations: { - // 居住场所 - "卧室": "bedroom", "房间": "bedroom", "寝室": "bedroom", - "客厅": "living room", "起居室": "living room", "大厅": "hall", - "厨房": "kitchen", "灶间": "kitchen", "餐厅": "dining room", - "浴室": "bathroom", "洗手间": "bathroom", "厕所": "toilet", - "阳台": "balcony", "露台": "terrace", "庭院": "courtyard", - "花园": "garden", "后院": "backyard", "前院": "front yard", - "地下室": "basement", "阁楼": "attic", "储藏室": "storage room", - - // 学校场所 - "教室": "classroom", "课堂": "classroom", "学校": "school", - "图书馆": "library", "书馆": "library", "阅览室": "reading room", - "实验室": "laboratory", "研究室": "laboratory", "计算机房": "computer room", - "体育馆": "gymnasium", "操场": "playground", "运动场": "sports field", - "食堂": "cafeteria", "宿舍": "dormitory", "社团室": "club room", - "校园": "campus", "校门": "school gate", "走廊": "corridor", - - // 工作场所 - "办公室": "office", "工作室": "office", "会议室": "meeting room", - "公司": "company", "企业": "corporation", "工厂": "factory", - "车间": "workshop", "仓库": "warehouse", "商店": "shop", - "超市": "supermarket", "商场": "shopping mall", "市场": "market", - "银行": "bank", "邮局": "post office", "政府": "government office", - - // 医疗场所 - "医院": "hospital", "诊所": "hospital", "急诊室": "emergency room", - "病房": "hospital room", "手术室": "operating room", "药房": "pharmacy", - - // 娱乐场所 - "咖啡厅": "cafe", "咖啡店": "cafe", "茶馆": "tea house", - "餐厅": "restaurant", "饭店": "restaurant", "快餐店": "fast food", - "酒吧": "bar", "夜店": "nightclub", "KTV": "karaoke", - "电影院": "cinema", "剧院": "theater", "音乐厅": "concert hall", - "游乐园": "amusement park", "动物园": "zoo", "水族馆": "aquarium", - "博物馆": "museum", "美术馆": "art gallery", "展览馆": "exhibition hall", - - // 自然场所 - "公园": "park", "广场": "square", "街道": "street", - "海边": "beach", "沙滩": "beach", "海滩": "beach", - "森林": "forest", "树林": "forest", "丛林": "jungle", - "山": "mountain", "高山": "mountain", "山顶": "mountain peak", - "湖边": "lake", "湖泊": "lake", "河边": "riverside", - "河流": "river", "溪流": "stream", "瀑布": "waterfall", - "草原": "grassland", "田野": "field", "农场": "farm", - "沙漠": "desert", "雪山": "snowy mountain", "冰川": "glacier", - - // 交通场所 - "火车站": "train station", "地铁站": "subway station", "公交站": "bus stop", - "机场": "airport", "港口": "port", "码头": "dock", - "停车场": "parking lot", "加油站": "gas station", - - // 宗教场所 - "教堂": "church", "寺庙": "temple", "清真寺": "mosque", - "神社": "shrine", "修道院": "monastery", - - // 特殊场所 - "城堡": "castle", "宫殿": "castle", "塔楼": "tower", - "桥": "bridge", "大桥": "bridge", "隧道": "tunnel", - "屋顶": "rooftop", "天台": "rooftop", "楼顶": "rooftop", - "地铁": "subway", "电梯": "elevator", "楼梯": "stairs", - "监狱": "prison", "法院": "courthouse", "警察局": "police station", - "温泉": "hot spring", "海岛": "island", "洞穴": "cave", - "废墟": "ruins", "遗迹": "ruins", "秘境": "secret place" - }, - - weather_time: { - // 天气 - "晴天": "sunny", "阳光": "sunny", "晴朗": "sunny", - "多云": "cloudy", "阴天": "cloudy", "乌云": "dark clouds", - "下雨": "rain", "雨天": "rainy", "雨": "rain", - "毛毛雨": "drizzle", "大雨": "heavy rain", "暴雨": "storm", - "雷雨": "thunderstorm", "闪电": "lightning", "打雷": "thunder", - "下雪": "snow", "雪天": "snowy", "雪": "snow", - "暴雪": "blizzard", "雪花": "snowflakes", "雪景": "snowy scene", - "雾": "fog", "薄雾": "mist", "浓雾": "thick fog", - "风": "wind", "微风": "breeze", "强风": "strong wind", - "台风": "typhoon", "龙卷风": "tornado", "沙尘暴": "sandstorm", - - // 时间 - "日出": "sunrise", "清晨": "morning", "早晨": "morning", - "上午": "morning", "中午": "noon", "下午": "afternoon", - "日落": "sunset", "黄昏": "sunset", "夕阳": "sunset", - "傍晚": "evening", "夜晚": "night", "晚上": "night", - "深夜": "night", "午夜": "midnight", "凌晨": "dawn", - "白天": "day", "日间": "day", "夜间": "night", - - // 季节 - "春天": "spring", "春季": "spring", "初春": "early spring", - "夏天": "summer", "夏季": "summer", "盛夏": "midsummer", - "秋天": "autumn", "秋季": "autumn", "深秋": "late autumn", - "冬天": "winter", "冬季": "winter", "隆冬": "midwinter", - - // 天象 - "月光": "moonlight", "满月": "full moon", "新月": "new moon", - "星空": "starry sky", "繁星": "stars", "银河": "milky way", - "彩虹": "rainbow", "双彩虹": "double rainbow", "流星": "meteor", - "日食": "solar eclipse", "月食": "lunar eclipse", "极光": "aurora", - - // 气候 - "炎热": "hot", "温暖": "warm", "凉爽": "cool", - "寒冷": "cold", "冰冷": "freezing", "严寒": "bitter cold", - "潮湿": "humid", "干燥": "dry", "闷热": "muggy" - }, - - colors: { - // 基础颜色 - "红色": "red", "红": "red", "朱红": "red", "深红": "dark red", - "粉色": "pink", "粉红": "pink", "粉": "pink", "浅粉": "light pink", - "橙色": "orange", "橘色": "orange", "橙": "orange", "橘红": "red orange", - "黄色": "yellow", "黄": "yellow", "金黄": "golden yellow", "柠檬黄": "lemon yellow", - "绿色": "green", "绿": "green", "翠绿": "emerald green", "深绿": "dark green", - "蓝色": "blue", "蓝": "blue", "天蓝": "sky blue", "深蓝": "dark blue", - "紫色": "purple", "紫": "purple", "紫罗兰": "violet", "深紫": "dark purple", - "黑色": "black", "黑": "black", "乌黑": "jet black", "深黑": "deep black", - "白色": "white", "白": "white", "洁白": "pure white", "雪白": "snow white", - "灰色": "gray", "灰": "gray", "银灰": "silver gray", "深灰": "dark gray", - "棕色": "brown", "褐色": "brown", "咖啡色": "coffee brown", "巧克力色": "chocolate", - - // 金属色 - "银色": "silver", "金色": "gold", "铜色": "copper", "青铜": "bronze", - "铂金": "platinum", "玫瑰金": "rose gold", - - // 特殊色彩 - "彩虹色": "rainbow", "渐变色": "gradient", "透明": "transparent", - "荧光": "fluorescent", "金属": "metallic", "珠光": "pearl", - "哑光": "matte", "亮光": "glossy", "闪光": "glitter" - }, - - objects: { - // 书籍文具 - "书": "book", "书本": "book", "图书": "book", "小说": "novel", - "教科书": "textbook", "字典": "dictionary", "杂志": "magazine", - "笔": "pen", "钢笔": "fountain pen", "铅笔": "pencil", "毛笔": "brush pen", - "纸": "paper", "笔记本": "notebook", "日记": "diary", "便签": "sticky note", - - // 花卉植物 - "花": "flower", "鲜花": "flower", "花朵": "flower", "花束": "bouquet", - "玫瑰": "rose", "樱花": "cherry blossom", "向日葵": "sunflower", - "郁金香": "tulip", "百合": "lily", "菊花": "chrysanthemum", - "树": "tree", "盆栽": "potted plant", "仙人掌": "cactus", - - // 餐具茶具 - "杯子": "cup", "茶杯": "teacup", "咖啡杯": "coffee cup", - "水杯": "water glass", "酒杯": "wine glass", "马克杯": "mug", - "盘子": "plate", "碗": "bowl", "勺子": "spoon", "叉子": "fork", - "筷子": "chopsticks", "刀": "knife", "茶壶": "teapot", - - // 装饰品 - "镜子": "mirror", "时钟": "clock", "钟": "clock", "闹钟": "alarm clock", - "相框": "photo frame", "画": "painting", "海报": "poster", - "蜡烛": "candle", "台灯": "desk lamp", "花瓶": "vase", - - // 武器道具 - "剑": "sword", "刀": "sword", "匕首": "dagger", "长矛": "spear", - "弓": "bow", "箭": "arrow", "盾": "shield", "铠甲": "armor", - "魔法棒": "magic wand", "法杖": "staff", "水晶球": "crystal ball", - - // 乐器 - "吉他": "guitar", "钢琴": "piano", "小提琴": "violin", - "笛子": "flute", "鼓": "drum", "萨克斯": "saxophone", - - // 电子设备 - "电脑": "computer", "笔记本": "laptop", "平板": "tablet", - "手机": "phone", "相机": "camera", "照相机": "camera", - "电视": "television", "收音机": "radio", "耳机": "headphones", - - // 日用品 - "伞": "umbrella", "雨伞": "umbrella", "遮阳伞": "parasol", - "包": "bag", "书包": "bag", "手提包": "handbag", "背包": "backpack", - "钱包": "wallet", "钥匙": "key", "锁": "lock", - "枕头": "pillow", "抱枕": "pillow", "毯子": "blanket", - "被子": "quilt", "床单": "bedsheet", "毛巾": "towel", - - // 交通工具 - "汽车": "car", "自行车": "bicycle", "摩托车": "motorcycle", - "公交车": "bus", "出租车": "taxi", "卡车": "truck", - "飞机": "airplane", "直升机": "helicopter", "船": "ship", - "游艇": "yacht", "火车": "train", "地铁": "subway", - - // 食物饮品 - "咖啡": "coffee", "茶": "tea", "水": "water", "果汁": "juice", - "蛋糕": "cake", "面包": "bread", "饼干": "cookie", - "苹果": "apple", "香蕉": "banana", "橙子": "orange", - "巧克力": "chocolate", "糖果": "candy", "冰淇淋": "ice cream", - - // 首饰配件 - "项链": "necklace", "手链": "bracelet", "戒指": "ring", - "耳环": "earrings", "胸针": "brooch", "手表": "watch", - "皇冠": "crown", "头饰": "hair accessory", "发卡": "hair clip", - - // 运动用品 - "球": "ball", "篮球": "basketball", "足球": "soccer ball", - "网球": "tennis ball", "乒乓球": "ping pong ball", - "球拍": "racket", "滑板": "skateboard", "轮滑鞋": "roller skates", - - // 玩具 - "玩偶": "doll", "泰迪熊": "teddy bear", "毛绒玩具": "stuffed animal", - "积木": "building blocks", "拼图": "puzzle", "棋": "chess", - "扑克": "playing cards", "骰子": "dice", "风筝": "kite" - }, - - styles: { - // 美感风格 - "可爱": "cute", "美丽": "beautiful", "漂亮": "pretty", - "美": "beautiful", "绝美": "stunning", "惊艳": "gorgeous", - "优雅": "elegant", "高贵": "noble", "华丽": "gorgeous", - "精致": "delicate", "完美": "perfect", "迷人": "charming", - - // 性感风格 - "性感": "sexy", "诱惑": "seductive", "魅惑": "seductive", - "撩人": "alluring", "火辣": "hot", "妖娆": "enchanting", - "风情": "charming", "妩媚": "seductive", "勾人": "alluring", - - // 纯真风格 - "清纯": "innocent", "纯洁": "pure", "天真": "innocent", - "单纯": "naive", "纯真": "pure", "清新": "fresh", - "自然": "natural", "朴素": "simple", "清雅": "elegant", - - // 成熟风格 - "成熟": "mature", "稳重": "mature", "知性": "intellectual", - "干练": "capable", "职业": "professional", "严谨": "rigorous", - "端庄": "dignified", "庄重": "solemn", "典雅": "elegant", - - // 活力风格 - "活泼": "lively", "开朗": "cheerful", "阳光": "bright", - "青春": "youthful", "朝气": "energetic", "活力": "vibrant", - "热情": "passionate", "积极": "positive", "乐观": "optimistic", - - // 冷酷风格 - "神秘": "mysterious", "冷酷": "cool", "高冷": "cold", - "冰冷": "icy", "冷漠": "indifferent", "疏离": "distant", - "孤独": "lonely", "忧郁": "melancholy", "深沉": "deep", - - // 温暖风格 - "温暖": "warm", "舒适": "comfortable", "宁静": "peaceful", - "温和": "gentle", "慈祥": "kind", "亲切": "friendly", - "贴心": "caring", "体贴": "considerate", "善良": "kind", - - // 浪漫风格 - "浪漫": "romantic", "梦幻": "dreamy", "唯美": "aesthetic", - "诗意": "poetic", "文艺": "artistic", "小清新": "fresh", - "治愈": "healing", "暖心": "heartwarming", "甜美": "sweet", - - // 奇幻风格 - "奇幻": "fantasy", "魔幻": "magical", "神秘": "mysterious", - "超现实": "surreal", "梦境": "dreamlike", "虚幻": "illusory", - - // 时代风格 - "古典": "classic", "复古": "vintage", "古风": "ancient style", - "现代": "modern", "时尚": "fashionable", "前卫": "avant-garde", - "未来": "futuristic", "科幻": "sci-fi", "赛博朋克": "cyberpunk", - - // 男性魅力 - "帅气": "handsome", "英俊": "handsome", "潇洒": "dashing", - "俊美": "handsome", "阳刚": "masculine", "威严": "dignified", - "强大": "powerful", "威猛": "mighty", "勇敢": "brave", - "绅士": "gentleman", "风度": "graceful", "魅力": "charismatic", - "霸气": "domineering", "王者": "kingly", "领袖": "leadership" - }, - - activities: { - // 学习活动 - "学习": "studying", "上课": "attending class", "考试": "exam", - "复习": "reviewing", "预习": "previewing", "做题": "solving problems", - "做作业": "homework", "写作业": "homework", "背书": "memorizing", - "研究": "research", "实验": "experiment", "讨论": "discussion", - - // 生活活动 - "做饭": "cooking", "吃饭": "eating", "用餐": "dining", - "喝茶": "drinking tea", "喝咖啡": "drinking coffee", "品茶": "tea tasting", - "洗澡": "bathing", "沐浴": "bathing", "泡澡": "bathing", - "洗漱": "washing up", "刷牙": "brushing teeth", "洗脸": "washing face", - "睡觉": "sleeping", "午睡": "napping", "休息": "resting", - "起床": "getting up", "醒来": "waking up", - - // 购物娱乐 - "购物": "shopping", "逛街": "shopping", "买东西": "shopping", - "逛商场": "mall shopping", "网购": "online shopping", - "看电影": "watching movie", "看电视": "watching TV", "追剧": "binge watching", - "听音乐": "listening to music", "唱歌": "singing", "唱K": "karaoke", - "游戏": "gaming", "玩耍": "playing", "娱乐": "entertainment", - "聊天": "chatting", "谈话": "talking", "交流": "communicating", - - // 运动健身 - "运动": "sports", "健身": "fitness", "锻炼": "exercise", - "跑步": "running", "慢跑": "jogging", "散步": "walking", - "游泳": "swimming", "潜水": "diving", "跳水": "diving", - "登山": "mountain climbing", "徒步": "hiking", "骑行": "cycling", - "瑜伽": "yoga", "舞蹈": "dancing", "跳舞": "dancing", - "太极": "tai chi", "武术": "martial arts", "拳击": "boxing", - - // 工作活动 - "工作": "working", "加班": "overtime", "会议": "meeting", - "开会": "attending meeting", "谈判": "negotiation", "签约": "signing contract", - "出差": "business trip", "培训": "training", "实习": "internship", - - // 社交活动 - "聚会": "party", "庆祝": "celebration", "生日": "birthday", - "聚餐": "group dining", "野餐": "picnic", "烧烤": "barbecue", - "约会": "dating", "恋爱": "romance", "表白": "confession", - "求婚": "proposal", "结婚": "wedding", "婚礼": "wedding ceremony", - "蜜月": "honeymoon", "旅行": "travel", "度假": "vacation", - "观光": "sightseeing", "旅游": "tourism", "探险": "adventure", - - // 文艺活动 - "看书": "reading", "阅读": "reading", "写作": "writing", - "画画": "drawing", "绘画": "painting", "摄影": "photography", - "书法": "calligraphy", "雕刻": "carving", "手工": "handicraft", - "编织": "knitting", "刺绣": "embroidery", "陶艺": "pottery", - - // 情感互动 - "调戏": "teasing", "戏弄": "teasing", "挑逗": "flirting", - "撩": "flirting", "撩拨": "flirting", "勾引": "seduction", - "诱惑": "seduction", "魅惑": "seduction", "撒娇": "acting cute", - "卖萌": "acting cute", "害羞": "shy", "脸红": "blushing", - "接吻": "kissing", "亲吻": "kissing", "亲": "kissing", - "拥抱": "hugging", "抱": "hugging", "搂": "embracing", - "牵手": "holding hands", "握手": "handshake", - "抚摸": "caressing", "爱抚": "caressing", "按摩": "massage", - "安慰": "comforting", "关心": "caring", "照顾": "taking care", - - // 窥视相关 - "偷看": "peeking", "窥视": "voyeur", "偷窥": "voyeur", - "暗中观察": "secretly observing", "跟踪": "following", - "展示": "showing", "炫耀": "showing off", "露出": "exposing", - "表现": "performing", "演示": "demonstrating", - - // 梦境活动 - "梦": "dreaming", "做梦": "dreaming", "梦见": "dreaming", - "梦游": "sleepwalking", "噩梦": "nightmare", "美梦": "sweet dream", - - // 思考活动 - "思考": "thinking", "考虑": "considering", "琢磨": "pondering", - "沉思": "contemplating", "反思": "reflecting", "冥想": "meditation", - "发呆": "daydreaming", "走神": "spacing out", "幻想": "fantasizing", - - // 创作活动 - "创作": "creating", "发明": "inventing", "设计": "designing", - "制作": "making", "建造": "building", "构建": "constructing", - "修理": "repairing", "维修": "fixing", "改造": "renovating" - }, - - body_parts: { - // 头部 - "头": "head", "头部": "head", "脑袋": "head", - "脸": "face", "面部": "face", "容颜": "face", - "额头": "forehead", "脸颊": "cheeks", "下巴": "chin", - "眼睛": "eyes", "眼": "eyes", "眼神": "gaze", "目光": "gaze", - "眉毛": "eyebrows", "睫毛": "eyelashes", "眼皮": "eyelids", - "鼻子": "nose", "鼻": "nose", "鼻梁": "nose bridge", - "嘴": "mouth", "嘴唇": "lips", "舌头": "tongue", - "牙齿": "teeth", "虎牙": "fangs", "门牙": "front teeth", - "耳朵": "ears", "耳": "ears", "耳垂": "earlobes", - "头发": "hair", "发型": "hairstyle", "刘海": "bangs", - - // 颈部胸部 - "脖子": "neck", "颈": "neck", "咽喉": "throat", - "肩膀": "shoulders", "肩": "shoulders", "锁骨": "collarbone", - "胸": "breasts", "胸部": "breasts", "乳房": "breasts", - "胸膛": "chest", "胸口": "chest", "心脏": "heart", - - // 手臂手部 - "手臂": "arms", "臂": "arms", "上臂": "upper arms", - "前臂": "forearms", "肘": "elbows", "肘部": "elbows", - "手": "hands", "手掌": "palms", "手背": "back of hands", - "手指": "fingers", "拇指": "thumbs", "食指": "index fingers", - "中指": "middle fingers", "无名指": "ring fingers", "小指": "pinky fingers", - "指甲": "nails", "手腕": "wrists", "腕": "wrists", - - // 躯干 - "身体": "body", "身材": "figure", "体型": "body type", - "背": "back", "后背": "back", "脊背": "spine", - "腰": "waist", "腰部": "waist", "细腰": "slim waist", - "肚子": "belly", "腹部": "abdomen", "腹": "abdomen", - "肚脐": "navel", "小腹": "lower abdomen", - "臀部": "hips", "屁股": "butt", "臀": "buttocks", - - // 腿部足部 - "腿": "legs", "大腿": "thighs", "小腿": "calves", - "膝盖": "knees", "膝": "knees", "脚踝": "ankles", - "脚": "feet", "足": "feet", "脚掌": "soles", - "脚趾": "toes", "脚指": "toes", "脚跟": "heels", - - // 皮肤相关 - "皮肤": "skin", "肌肤": "skin", "体肤": "skin", - "毛孔": "pores", "汗": "sweat", "体温": "body temperature", - - // 内衣相关 - "胸罩": "bra", "文胸": "bra", "内衣": "underwear", - "内裤": "panties", "底裤": "underwear", "三角裤": "briefs", - "胖次": "panties", "安全裤": "safety shorts" - }, - - nsfw_actions: { - // 基础行为 - "做爱": "sex", "性爱": "sex", "交配": "mating", "性交": "intercourse", - "爱爱": "making love", "啪啪": "sex", "嘿咻": "sex", - - // 插入动作 - "插入": "penetration", "进入": "penetration", "插": "insertion", - "深入": "deep penetration", "浅入": "shallow penetration", - "刺入": "thrusting in", "顶入": "pushing in", - - // 律动动作 - "抽插": "thrusting", "律动": "thrusting", "顶": "thrusting", - "冲撞": "pounding", "撞击": "hitting", "摩擦": "rubbing", - "研磨": "grinding", "扭动": "twisting", "起伏": "undulating", - - // 高潮相关 - "高潮": "orgasm", "达到高潮": "climax", "巅峰": "peak", - "射精": "ejaculation", "释放": "release", "爆发": "explosion", - "喷": "squirting", "涌出": "gushing", "流出": "flowing", - - // 口部动作 - "口交": "oral sex", "含": "sucking", "舔": "licking", - "吸": "sucking", "吮": "sucking", "咬": "biting", - "亲": "kissing", "深吻": "deep kiss", "法式接吻": "french kiss", - - // 体位相关 - "肛交": "anal sex", "后入": "doggy style", "骑乘": "cowgirl", - "传教士": "missionary", "侧位": "side position", "反向": "reverse", - "站立": "standing position", "坐位": "sitting position", - - // 自慰相关 - "手淫": "masturbation", "自慰": "masturbation", "撸": "stroking", - "套弄": "stroking", "摩擦": "rubbing", "刺激": "stimulation", - - // 抚摸动作 - "指交": "fingering", "抚弄": "fondling", "揉": "massaging", - "搓": "rubbing", "捏": "pinching", "压": "pressing", - "按": "pressing", "推": "pushing", "拉": "pulling", - - // 体液相关 - "爱液": "love juice", "精液": "semen", "体液": "bodily fluids", - "分泌": "secretion", "润滑": "lubrication", "湿润": "moisture", - - // 状态描述 - "湿润": "wet", "润滑": "lubricated", "干燥": "dry", - "紧": "tight", "松": "loose", "深": "deep", "浅": "shallow", - "热": "hot", "温暖": "warm", "冰凉": "cold", - "快": "fast", "慢": "slow", "用力": "hard", "轻": "gentle", - "粗暴": "rough", "温柔": "gentle", "激烈": "intense", "缓慢": "slow" - }, - - nsfw_body_parts: { - // 男性器官 - "阴茎": "penis", "鸡巴": "cock", "肉棒": "dick", "老二": "dick", - "鸡鸡": "penis", "小弟弟": "penis", "那话儿": "penis", - "龟头": "glans", "包皮": "foreskin", "马眼": "urethral opening", - "睾丸": "testicles", "蛋蛋": "balls", "精囊": "seminal vesicles", - - // 女性器官 - "阴道": "vagina", "小穴": "pussy", "阴唇": "labia", "花瓣": "labia", - "阴蒂": "clitoris", "豆豆": "clitoris", "小核": "clitoris", - "阴户": "vulva", "私处": "private parts", "花径": "vagina", - "子宫": "womb", "宫口": "cervix", "G点": "g-spot", "敏感点": "sensitive spot", - - // 共同部位 - "肛门": "anus", "菊花": "asshole", "后庭": "backdoor", "屁眼": "butthole", - "会阴": "perineum", "下体": "genitals", "性器": "sex organ", - "私密处": "intimate parts", "敏感带": "erogenous zone", - - // 胸部 - "乳头": "nipples", "奶头": "nipples", "乳晕": "areola", - "奶子": "tits", "胸脯": "breasts", "酥胸": "soft breasts", - "双峰": "twin peaks", "玉兔": "breasts", "雪峰": "white breasts", - - // 其他敏感部位 - "大腿根": "inner thighs", "腿间": "between legs", "股间": "crotch", - "后穴": "back hole", "前穴": "front hole", "蜜穴": "honey pot", - "花心": "deep inside", "花芯": "core", "深处": "deep inside", - - // 生理反应 - "勃起": "erection", "坚挺": "stiff", "充血": "engorged", - "湿润": "wet", "分泌": "secreting", "流水": "dripping", - "收缩": "contracting", "痉挛": "spasming", "颤抖": "trembling", - - // 特殊词汇 - "前列腺": "prostate", "尿道": "urethra", "处女膜": "hymen", - "欲火": "lust", "春情": "arousal", "情欲": "passion" - }, - - nsfw_states: { - // 男性状态 - "勃起": "erect", "硬": "hard", "坚挺": "stiff", "挺立": "standing", - "半勃": "semi-erect", "软": "soft", "疲软": "limp", - "胀大": "swollen", "充血": "engorged", "青筋暴起": "veiny", - - // 女性状态 - "湿": "wet", "潮湿": "moist", "流水": "dripping", "湿润": "lubricated", - "干涩": "dry", "紧致": "tight", "松弛": "loose", - "夹紧": "clenching", "收缩": "contracting", "痉挛": "spasming", - - // 共同状态 - "胀": "swollen", "肿": "enlarged", "充血": "engorged", - "敏感": "sensitive", "酥麻": "tingling", "颤抖": "trembling", - "战栗": "shivering", "痉挛": "convulsing", "抽搐": "twitching", - - // 情绪状态 - "兴奋": "aroused", "激动": "excited", "冲动": "horny", - "发情": "in heat", "春心荡漾": "aroused", "欲火焚身": "lustful", - "欲火": "lustful", "渴望": "craving", "饥渴": "thirsty", - "急需": "desperate", "忍耐": "enduring", "煎熬": "suffering", - - // 满足状态 - "满足": "satisfied", "充实": "fulfilled", "空虚": "empty", - "饱满": "full", "撑胀": "stretched", "填满": "filled", - "深入": "deep", "顶到": "hitting", "碰到": "touching", - - // 感觉状态 - "疼": "painful", "痛": "aching", "酸": "sore", - "爽": "pleasurable", "舒服": "comfortable", "快感": "pleasure", - "酥": "tingling", "麻": "numb", "痒": "itchy", - "热": "hot", "烫": "burning", "凉": "cool", - "涨": "swelling", "胀": "bloated", "紧": "tight", - - // 程度状态 - "轻微": "slight", "强烈": "intense", "剧烈": "violent", - "温和": "gentle", "激烈": "fierce", "疯狂": "crazy", - "缓慢": "slow", "急促": "rapid", "持续": "continuous" - }, - - nsfw_sounds: { - // 呻吟声 - "呻吟": "moaning", "叫床": "moaning", "娇喘": "panting", - "喘息": "breathing heavily", "急喘": "panting", "粗喘": "heavy breathing", - - // 基础音节 - "哼": "humming", "嗯": "mmm", "唔": "mmm", - "啊": "ah", "哦": "oh", "噢": "oh", - "嘤": "whimpering", "嘤嘤": "whimpering", "嘤嘤嘤": "whimpering", - - // 高音调 - "尖叫": "screaming", "尖声": "high-pitched", "细声": "thin voice", - "呼喊": "crying out", "大叫": "shouting", "惊叫": "exclaiming", - - // 低音调 - "低吟": "groaning", "闷哼": "muffled moan", "低喃": "mumbling", - "嘟囔": "muttering", "咕哝": "grumbling", "轻哼": "soft humming", - - // 情绪音 - "啜泣": "sobbing", "哽咽": "choking", "抽泣": "sniffling", - "颤音": "trembling voice", "破音": "voice breaking", - - // 生理音 - "喘气": "gasping", "倒抽气": "sharp intake", "屏息": "holding breath", - "换气": "catching breath", "深呼吸": "deep breathing", - - // 其他音效 - "叫声": "vocal", "声音": "sounds", "噪音": "noise", - "音调": "tone", "音量": "volume", "回音": "echo", - "轻声": "whisper", "细语": "soft voice", "耳语": "whispering", - "颤抖": "trembling", "战栗": "shivering", "哆嗦": "quivering" - }, - - nsfw_descriptions: { - // 基础描述 - "色情": "pornographic", "淫荡": "lewd", "下流": "vulgar", - "猥亵": "obscene", "淫秽": "indecent", "不雅": "improper", - "淫乱": "promiscuous", "放荡": "wanton", "骚": "slutty", - "浪": "naughty", "风骚": "seductive", "妖艳": "bewitching", - - // 性格特征 - "骚货": "slut", "淫娃": "sex kitten", "小妖精": "little minx", - "小浪蹄子": "little slut", "狐狸精": "vixen", "妖女": "seductress", - "处女": "virgin", "纯洁": "pure", "清纯": "innocent", - "无辜": "innocent", "天真": "naive", "单纯": "simple", - - // 经验程度 - "经验": "experienced", "老练": "skilled", "熟练": "proficient", - "熟女": "mature woman", "老司机": "experienced", "新手": "beginner", - "生涩": "inexperienced", "青涩": "green", "稚嫩": "tender", - - // 特殊嗜好 - "禁忌": "taboo", "变态": "pervert", "扭曲": "twisted", - "病态": "sick", "不正常": "abnormal", "特殊": "special", - "癖好": "fetish", "嗜好": "preference", "口味": "taste", - - // 权力关系 - "调教": "training", "驯服": "taming", "征服": "conquering", - "支配": "domination", "统治": "ruling", "控制": "control", - "服从": "submission", "屈服": "yielding", "顺从": "obedient", - "奴隶": "slave", "奴": "slave", "宠物": "pet", - "主人": "master", "主": "master", "女王": "queen", - "女主": "mistress", "王": "king", "君主": "sovereign", - - // 强度描述 - "轻柔": "gentle", "温和": "mild", "激烈": "intense", - "粗暴": "rough", "野蛮": "savage", "狂野": "wild", - "疯狂": "crazy", "极端": "extreme", "过分": "excessive" - }, - - intimate_settings: { - // 私密场所 - "床": "bed", "床上": "on bed", "大床": "big bed", - "单人床": "single bed", "双人床": "double bed", "水床": "waterbed", - "床单": "bedsheet", "被子": "blanket", "枕头": "pillow", - "被窝": "under blanket", "毯子": "blanket", "软垫": "soft mat", - - // 卧室环境 - "卧室": "bedroom", "主卧": "master bedroom", "客房": "guest room", - "宿舍": "dormitory", "公寓": "apartment", "套房": "suite", - "酒店房间": "hotel room", "民宿": "bed and breakfast", - - // 浴室场所 - "浴室": "bathroom", "洗手间": "bathroom", "淋浴间": "shower room", - "浴缸": "bathtub", "按摩浴缸": "jacuzzi", "淋浴": "shower", - "蒸汽浴": "steam bath", "桑拿": "sauna", "温泉": "hot spring", - - // 客厅家具 - "沙发": "sofa", "长沙发": "couch", "皮沙发": "leather sofa", - "躺椅": "recliner", "懒人椅": "lazy chair", "摇椅": "rocking chair", - "地毯": "carpet", "地垫": "mat", "地板": "floor", - "茶几": "coffee table", "边桌": "side table", - - // 其他家具 - "桌子": "table", "书桌": "desk", "梳妆台": "dressing table", - "椅子": "chair", "办公椅": "office chair", "吧台椅": "bar stool", - "墙": "wall", "墙角": "corner", "窗台": "windowsill", - "阳台": "balcony", "露台": "terrace", "天台": "rooftop", - - // 交通工具 - "车里": "in car", "后座": "back seat", "驾驶座": "driver seat", - "副驾驶": "passenger seat", "货车": "truck", "面包车": "van", - "火车": "train", "飞机": "airplane", "游艇": "yacht", - - // 户外场所 - "野外": "outdoors", "森林": "forest", "树林": "woods", - "海滩": "beach", "沙滩": "sandy beach", "海边": "seaside", - "草地": "grassland", "花园": "garden", "公园": "park", - "山顶": "mountain top", "山洞": "cave", "帐篷": "tent", - - // 特殊场所 - "办公室": "office", "会议室": "meeting room", "储藏室": "storage room", - "教室": "classroom", "图书馆": "library", "实验室": "laboratory", - "厕所": "toilet", "洗手间": "restroom", "更衣室": "changing room", - "试衣间": "fitting room", "化妆间": "dressing room", - "健身房": "gym", "瑜伽室": "yoga room", "舞蹈室": "dance studio", - - // 住宿场所 - "酒店": "hotel", "旅馆": "motel", "民宿": "guesthouse", - "度假村": "resort", "别墅": "villa", "小屋": "cabin", - "招待所": "hostel", "青旅": "youth hostel" - }, - - fetish_categories: { - // 服装恋物 - "丝袜": "stockings", "黑丝": "black stockings", "白丝": "white stockings", - "连裤袜": "pantyhose", "网袜": "fishnet stockings", "过膝袜": "thigh highs", - "高跟鞋": "high heels", "靴子": "boots", "长靴": "knee boots", - "制服": "uniform", "学生装": "school uniform", "护士装": "nurse outfit", - "女仆装": "maid outfit", "空姐装": "flight attendant uniform", - - // 材质恋物 - "蕾丝": "lace", "真丝": "silk", "缎子": "satin", - "皮革": "leather", "乳胶": "latex", "橡胶": "rubber", - "PVC": "pvc", "金属": "metal", "链条": "chain", - - // 束缚用具 - "束缚": "bondage", "绳子": "rope", "绳索": "rope", - "手铐": "handcuffs", "脚镣": "shackles", "锁链": "chains", - "眼罩": "blindfold", "口球": "gag", "项圈": "collar", - "皮带": "belt", "背带": "harness", "束身衣": "corset", - - // 调教用具 - "鞭子": "whip", "皮鞭": "leather whip", "马鞭": "riding crop", - "板子": "paddle", "藤条": "cane", "羽毛": "feather", - "蜡烛": "candle", "蜡油": "wax", "冰块": "ice", - "夹子": "clamps", "乳夹": "nipple clamps", "刑具": "torture device", - - // 情趣用品 - "玩具": "toy", "按摩棒": "vibrator", "震动棒": "vibrator", - "假阳具": "dildo", "双头龙": "double dildo", "仿真器": "realistic toy", - "跳蛋": "bullet vibrator", "遥控器": "remote control", "震动器": "vibrator", - "肛塞": "butt plug", "前列腺": "prostate massager", "扩张器": "dilator", - "充气娃娃": "sex doll", "飞机杯": "masturbator", "倒模": "pocket pussy", - - // 特殊恋物 - "触手": "tentacle", "怪物": "monster", "野兽": "beast", - "异形": "alien", "机器人": "robot", "人偶": "doll", - "机器": "machine", "机械": "mechanical", "人工": "artificial", - "科技": "technology", "虚拟": "virtual", "全息": "holographic", - - // 材质特殊 - "毛绒": "fur", "羽毛": "feather", "丝绸": "silk", - "天鹅绒": "velvet", "绒毛": "fuzzy", "光滑": "smooth", - "粗糙": "rough", "硬质": "hard", "软质": "soft" - }, - - body_modifications: { - // 纹身类型 - "纹身": "tattoo", "刺青": "tattoo", "花臂": "sleeve tattoo", - "图腾": "tribal tattoo", "文字": "text tattoo", "图案": "pattern tattoo", - "彩绘": "body painting", "临时纹身": "temporary tattoo", - "传统纹身": "traditional tattoo", "日式纹身": "japanese tattoo", - - // 穿孔类型 - "穿孔": "piercing", "打洞": "piercing", "耳洞": "ear piercing", - "鼻环": "nose ring", "唇环": "lip ring", "舌环": "tongue piercing", - "肚脐环": "navel piercing", "乳环": "nipple piercing", - "私处穿孔": "genital piercing", "眉环": "eyebrow piercing", - - // 自然标记 - "疤痕": "scar", "伤疤": "scar", "刀疤": "knife scar", - "胎记": "birthmark", "痣": "mole", "黑痣": "dark mole", - "雀斑": "freckles", "斑点": "spots", "色斑": "pigmentation", - "美人痣": "beauty mark", "泪痣": "tear mole", - - // 肌肉特征 - "肌肉": "muscle", "腹肌": "abs", "六块腹肌": "six pack", - "八块腹肌": "eight pack", "人鱼线": "v-line", "马甲线": "ab line", - "肱二头肌": "biceps", "胸肌": "pectoral muscles", "背肌": "back muscles", - "臀肌": "glutes", "大腿肌": "thigh muscles", "小腿肌": "calf muscles", - - // 骨骼特征 - "锁骨": "collarbone", "肩胛骨": "shoulder blade", "脊椎": "spine", - "肋骨": "ribs", "髋骨": "hip bone", "颧骨": "cheekbone", - "下颌": "jawline", "尖下巴": "pointed chin", "方下巴": "square jaw", - - // 身体凹陷 - "腰窝": "dimples", "酒窝": "dimples", "梨涡": "dimples", - "锁骨窝": "collarbone hollow", "太阳穴": "temples", - "颈窝": "neck hollow", "脚踝窝": "ankle hollow", - - // 特殊特征 - "虎牙": "fangs", "小虎牙": "small fangs", "门牙": "front teeth", - "双眼皮": "double eyelids", "单眼皮": "single eyelids", - "卧蚕": "aegyo sal", "眼袋": "eye bags", "鱼尾纹": "crow's feet", - "法令纹": "nasolabial folds", "颈纹": "neck lines" - }, - - clothing_states: { - // 脱衣状态 - "裸体": "nude", "全裸": "completely nude", "一丝不挂": "stark naked", - "半裸": "topless", "上身裸体": "topless", "下身裸体": "bottomless", - "微露": "slightly exposed", "若隐若现": "faintly visible", - - // 穿着状态 - "穿戴整齐": "fully dressed", "衣冠楚楚": "well-dressed", - "衣衫不整": "disheveled", "衣不蔽体": "barely clothed", - "衣衫褴褛": "ragged clothes", "破烂": "tattered", - - // 材质状态 - "透明": "transparent", "半透明": "see-through", "透视": "see-through", - "薄": "thin", "厚": "thick", "轻薄": "light", - "厚重": "heavy", "柔软": "soft", "粗糙": "rough", - "光滑": "smooth", "有光泽": "glossy", "无光": "matte", - - // 合身程度 - "紧身": "tight", "贴身": "form-fitting", "修身": "slim-fit", - "宽松": "loose", "肥大": "oversized", "合身": "well-fitted", - "过大": "too big", "过小": "too small", "刚好": "just right", - - // 长度状态 - "短": "short", "超短": "very short", "迷你": "mini", - "长": "long", "超长": "very long", "及地": "floor-length", - "中等": "medium", "标准": "standard", "正常": "normal", - - // 暴露程度 - "露": "exposed", "露出": "showing", "展示": "displaying", - "暴露": "revealing", "性感": "sexy", "保守": "conservative", - "大胆": "bold", "开放": "open", "含蓄": "modest", - "若隐若现": "peek-a-boo", "欲盖弥彰": "teasingly covered", - - // 穿脱动作 - "脱": "undressing", "脱下": "taking off", "褪去": "removing", - "穿": "dressing", "穿上": "putting on", "套": "slipping on", - "换": "changing", "更衣": "changing clothes", "试穿": "trying on", - "扯": "pulling", "撕": "tearing", "剪": "cutting", - - // 衣物状态 - "破": "torn", "破洞": "holes", "开口": "opening", - "裂缝": "crack", "撕裂": "ripped", "磨损": "worn", - "湿": "wet", "潮湿": "damp", "浸湿": "soaked", - "干": "dry", "干燥": "dried", "干净": "clean", - "脏": "dirty", "污": "stained", "染色": "colored", - "乱": "messy", "凌乱": "disheveled", "整齐": "neat", - "皱": "wrinkled", "平整": "smooth", "熨烫": "ironed" - }, - - romance_keywords: { - // 关系称谓 - "恋人": "lovers", "情侣": "couple", "爱侣": "lovers", - "男友": "boyfriend", "女友": "girlfriend", "伴侣": "partner", - "爱人": "lover", "心上人": "sweetheart", "意中人": "beloved", - "真爱": "true love", "挚爱": "beloved", "最爱": "favorite", - - // 恋爱类型 - "初恋": "first love", "暗恋": "crush", "单恋": "unrequited love", - "热恋": "passionate love", "苦恋": "painful love", "禁恋": "forbidden love", - "师生恋": "teacher-student romance", "办公室恋情": "office romance", - "远距离恋爱": "long distance relationship", "网恋": "online romance", - - // 情感状态 - "心动": "heartbeat", "怦然心动": "heart racing", "一见钟情": "love at first sight", - "脸红心跳": "blushing", "心跳加速": "racing heart", "心如鹿撞": "heart pounding", - "心花怒放": "heart blooming", "心潮澎湃": "surging emotions", - "情不自禁": "can't help oneself", "难以自拔": "unable to extricate", - - // 甜蜜情感 - "甜蜜": "sweet", "温馨": "warm", "浪漫": "romantic", - "幸福": "happy", "快乐": "joyful", "满足": "satisfied", - "陶醉": "intoxicated", "沉醉": "drunk with love", "痴迷": "infatuated", - "甜腻": "sickeningly sweet", "蜜糖": "honey", "糖分": "sweetness", - - // 思念情感 - "想念": "missing", "思念": "longing", "牵挂": "caring", - "惦记": "thinking of", "念念不忘": "unforgettable", "朝思暮想": "thinking day and night", - "魂牵梦绕": "haunting dreams", "日思夜想": "thinking constantly", - "相思": "lovesickness", "离愁": "separation sorrow", - - // 嫉妒情感 - "嫉妒": "jealous", "吃醋": "jealous", "争风吃醋": "jealous rivalry", - "醋意": "jealousy", "占有欲": "possessiveness", "独占": "monopolize", - "不安": "unease", "担心": "worry", "猜疑": "suspicion", - - // 分合状态 - "表白": "confession", "告白": "confession", "求爱": "courtship", - "追求": "pursuit", "示爱": "showing love", "求婚": "proposal", - "订婚": "engagement", "结婚": "marriage", "蜜月": "honeymoon", - "分手": "breakup", "分离": "separation", "离别": "parting", - "复合": "reunion", "和好": "reconcile", "重归于好": "getting back together", - - // 亲密行为 - "约会": "dating", "约会": "date", "幽会": "rendezvous", - "散步": "walk together", "看电影": "watch movie", "吃饭": "dinner date", - "牵手": "holding hands", "拥抱": "hugging", "接吻": "kissing", - "依偎": "cuddling", "偎依": "snuggling", "相拥": "embracing", - - // 情话表达 - "情话": "love words", "甜言蜜语": "sweet words", "告白": "confession", - "承诺": "promise", "誓言": "vow", "山盟海誓": "eternal vow", - "海枯石烂": "until seas dry", "天长地久": "everlasting", - "白头偕老": "grow old together", "永结同心": "united forever", - - // 情感深度 - "深爱": "deep love", "挚爱": "cherished love", "痴情": "devoted love", - "专情": "faithful love", "深情": "deep affection", "真情": "true feelings", - "纯情": "pure love", "真心": "sincere heart", "诚意": "sincerity", - "用心": "heartfelt", "全心全意": "wholeheartedly", "一心一意": "single-minded" - }, - - emotional_states: { - // 欲望相关 - "欲望": "desire", "渴望": "longing", "冲动": "impulse", - "饥渴": "thirsty", "急需": "desperate", "迫切": "urgent", - "强烈": "intense", "炽热": "burning", "火热": "passionate", - "狂野": "wild", "疯狂": "crazy", "失控": "out of control", - - // 兴奋状态 - "兴奋": "excited", "激动": "aroused", "亢奋": "euphoric", - "刺激": "stimulation", "快感": "pleasure", "爽": "pleasurable", - "舒服": "comfortable", "畅快": "exhilarating", "痛快": "satisfying", - "过瘾": "addictive", "上瘾": "addicted", "沉迷": "obsessed", - - // 满足状态 - "满足": "satisfied", "充实": "fulfilled", "完整": "complete", - "愉悦": "pleasure", "快乐": "joy", "幸福": "happiness", - "陶醉": "intoxicated", "沉醉": "drunk", "迷醉": "enchanted", - "销魂": "ecstatic", "飘飘然": "floating", "如痴如醉": "mesmerized", - - // 紧张焦虑 - "紧张": "nervous", "不安": "anxious", "忐忑": "restless", - "慌张": "flustered", "手足无措": "at a loss", "局促": "awkward", - "窘迫": "embarrassed", "尴尬": "awkward", "难堪": "mortified", - "焦虑": "anxious", "担忧": "worried", "忧虑": "concerned", - - // 期待好奇 - "期待": "anticipation", "盼望": "looking forward", "向往": "yearning", - "好奇": "curious", "感兴趣": "interested", "想知道": "wondering", - "探索": "exploration", "发现": "discovery", "新奇": "novelty", - "惊喜": "surprise", "意外": "unexpected", "震撼": "shocking", - - // 羞耻害羞 - "羞耻": "shame", "羞愧": "ashamed", "惭愧": "guilty", - "不好意思": "embarrassed", "难为情": "shy", "脸红": "blushing", - "害羞": "shy", "腼腆": "bashful", "扭捏": "coy", - "矜持": "reserved", "含蓄": "modest", "内敛": "introverted", - - // 大胆主动 - "大胆": "bold", "勇敢": "brave", "无畏": "fearless", - "主动": "proactive", "积极": "active", "进取": "aggressive", - "直接": "direct", "坦率": "frank", "开放": "open", - "放得开": "uninhibited", "豪放": "unrestrained", "奔放": "wild", - - // 被动顺从 - "被动": "passive", "消极": "negative", "退缩": "withdrawn", - "顺从": "submissive", "听话": "obedient", "乖巧": "well-behaved", - "温顺": "docile", "柔顺": "gentle", "配合": "cooperative", - "依赖": "dependent", "依恋": "attached", "粘人": "clingy", - - // 反抗挣扎 - "反抗": "resistant", "抗拒": "resisting", "反对": "opposing", - "挣扎": "struggling", "反抗": "rebelling", "违抗": "defying", - "拒绝": "refusing", "推辞": "declining", "回避": "avoiding", - "逃避": "escaping", "躲避": "hiding", "闪躲": "dodging", - - // 情感波动 - "矛盾": "conflicted", "纠结": "tangled", "复杂": "complicated", - "混乱": "confused", "迷茫": "lost", "困惑": "puzzled", - "犹豫": "hesitant", "踌躇": "hesitating", "不决": "undecided", - "摇摆": "wavering", "动摇": "shaken", "不定": "unstable" - } -}; - -let isProcessing = false; -let currentProgressButton = null; -let processedMessages = new Map(); -let currentImageUrl = null; -let currentSettings = null; -let lastScreenSize = null; - -function getCurrentScreenSize() { - return window.innerWidth <= 1000 ? 'small' : 'large'; -} - -function handleWindowResize() { - if (!isActive()) return; - - const currentScreenSize = getCurrentScreenSize(); - - if (lastScreenSize && lastScreenSize !== currentScreenSize && currentImageUrl && currentSettings) { - $('#wallhaven-app-background, #wallhaven-chat-background').remove(); - $('#wallhaven-app-overlay, #wallhaven-chat-overlay').remove(); - - applyBackgroundToApp(currentImageUrl, currentSettings); - } - - lastScreenSize = currentScreenSize; -} - -function clearBackgroundState() { - document.querySelectorAll('[id^="wallhaven-"]').forEach(el => el.remove()); - currentImageUrl = null; - currentSettings = null; - lastScreenSize = null; -} - -function getWallhavenSettings() { - if (!extension_settings[EXT_ID].wallhavenBackground) { - extension_settings[EXT_ID].wallhavenBackground = structuredClone(defaultSettings); - } - const settings = extension_settings[EXT_ID].wallhavenBackground; - for (const key in defaultSettings) { - if (settings[key] === undefined) { - settings[key] = defaultSettings[key]; - } - } - return settings; -} - -function isActive() { - if (!window.isXiaobaixEnabled) return false; - const settings = getWallhavenSettings(); - return settings.enabled; -} - -function isLandscapeOrientation() { - return window.innerWidth > window.innerHeight; -} - -function getRatiosForOrientation() { - if (isLandscapeOrientation()) { - return "16x9,16x10,21x9"; - } else { - return "9x16,10x16,1x1,9x18"; - } -} - -function showProgressInMessageHeader(messageElement, text) { - const flexContainer = messageElement.querySelector('.flex-container.flex1.alignitemscenter'); - if (!flexContainer) return null; - - removeProgressFromMessageHeader(); - - const progressButton = document.createElement('div'); - progressButton.className = 'mes_btn wallhaven_progress_indicator'; - progressButton.style.cssText = ` - color: #007acc !important; - cursor: default !important; - font-size: 11px !important; - padding: 2px 6px !important; - opacity: 0.9; - `; - progressButton.innerHTML = `${text}`; - progressButton.title = '正在为消息生成配图...'; - - flexContainer.appendChild(progressButton); - currentProgressButton = progressButton; - - return progressButton; -} - -function updateProgressText(text) { - if (currentProgressButton) { - currentProgressButton.innerHTML = `${text}`; - } -} - -function removeProgressFromMessageHeader() { - if (currentProgressButton) { - currentProgressButton.remove(); - currentProgressButton = null; - } - document.querySelectorAll('.wallhaven_progress_indicator').forEach(el => el.remove()); -} - -function renderCustomTagsList() { - const settings = getWallhavenSettings(); - const container = document.getElementById('wallhaven_custom_tags_list'); - if (!container) return; - - container.innerHTML = ''; - - if (!settings.customTags || settings.customTags.length === 0) { - container.innerHTML = '
暂无自定义标签
'; - return; - } - - settings.customTags.forEach(tag => { - const tagElement = document.createElement('div'); - tagElement.className = 'custom-tag-item'; - tagElement.innerHTML = ` - ${tag} - × - `; - container.appendChild(tagElement); - }); - - container.querySelectorAll('.custom-tag-remove').forEach(btn => { - btn.addEventListener('click', function() { - removeCustomTag(this.dataset.tag); - }); - }); -} - -function addCustomTag(tag) { - if (!tag || !tag.trim()) return; - - tag = tag.trim().toLowerCase(); - const settings = getWallhavenSettings(); - - if (!settings.customTags) { - settings.customTags = []; - } - - if (settings.customTags.includes(tag)) { - return false; - } - - settings.customTags.push(tag); - saveSettingsDebounced(); - renderCustomTagsList(); - return true; -} - -function removeCustomTag(tag) { - const settings = getWallhavenSettings(); - if (!settings.customTags) return; - - const index = settings.customTags.indexOf(tag); - if (index > -1) { - settings.customTags.splice(index, 1); - saveSettingsDebounced(); - renderCustomTagsList(); - } -} - -function extractTagsFromText(text, isBgMode = false) { - const settings = getWallhavenSettings(); - - const customTagObjs = (settings.customTags || []).map(tag => ({ - tag: tag, - category: 'custom', - weight: tagWeights.custom, - position: text.lastIndexOf(tag) - })); - - if (isBgMode) { - const bgCategories = ['locations', 'weather_time', 'objects']; - const tagsByCategory = {}; - - bgCategories.forEach(category => { - tagsByCategory[category] = []; - if (wallhavenTags[category]) { - Object.entries(wallhavenTags[category]).forEach(([chinese, english]) => { - const lastPos = text.lastIndexOf(chinese); - if (lastPos !== -1) { - tagsByCategory[category].push({ - tag: english, - category: category, - weight: tagWeights[category] || 1, - position: lastPos, - chinese: chinese - }); - } - }); - } - }); - - const selectedTags = [...customTagObjs]; - - Object.entries(tagsByCategory).forEach(([category, tags]) => { - if (tags.length === 0) return; - - tags.sort((a, b) => b.position - a.position); - - const selectedFromCategory = tags.slice(0, 1); - selectedFromCategory.forEach(tagObj => { - selectedTags.push({ - tag: tagObj.tag, - category: tagObj.category, - weight: tagObj.weight - }); - }); - }); - - if (selectedTags.length === customTagObjs.length) { - selectedTags.push({ tag: 'landscape', category: 'background_fallback', weight: 1 }); - } - - return { tags: selectedTags }; - } else { - - const tagsByCategory = {}; - - Object.keys(wallhavenTags).forEach(category => { - tagsByCategory[category] = []; - Object.entries(wallhavenTags[category]).forEach(([chinese, english]) => { - const lastPos = text.lastIndexOf(chinese); - if (lastPos !== -1) { - tagsByCategory[category].push({ - tag: english, - category: category, - weight: tagWeights[category] || 1, - position: lastPos, - chinese: chinese - }); - } - }); - }); - - const selectedTags = [...customTagObjs]; - - Object.entries(tagsByCategory).forEach(([category, tags]) => { - if (tags.length === 0) return; - - tags.sort((a, b) => b.position - a.position); - - let maxCount = 1; - if (['characters', 'clothing', 'body_features'].includes(category)) { - maxCount = 2; - } - - const selectedFromCategory = tags.slice(0, maxCount); - selectedFromCategory.forEach(tagObj => { - selectedTags.push({ - tag: tagObj.tag, - category: tagObj.category, - weight: tagObj.weight - }); - }); - }); - - return { tags: selectedTags }; - } -} - -async function fetchWithCFWorker(targetUrl) { - const cfWorkerUrl = 'https://wallhaven.velure.top/?url='; - const finalUrl = cfWorkerUrl + encodeURIComponent(targetUrl); - - const response = await fetch(finalUrl); - if (!response.ok) { - throw new Error(`CF Worker请求失败: HTTP ${response.status} - ${response.statusText}`); - } - return response; -} - -async function searchSingleTag(tagObj, category, purity, isBgMode) { - let searchTag = tagObj.tag; - if (isBgMode) { - searchTag = `${tagObj.tag} -girl -male -people -anime`; - } - const ratios = getRatiosForOrientation(); - const wallhavenUrl = `https://wallhaven.cc/api/v1/search?q=${encodeURIComponent(searchTag)}&categories=${category}&purity=${purity}&ratios=${ratios}&sorting=favorites&page=1&`; - - try { - const response = await fetchWithCFWorker(wallhavenUrl); - const data = await response.json(); - return { - tagObj: tagObj, - success: true, - total: data.meta.total, - images: data.data || [] - }; - } catch (error) { - return { - tagObj: tagObj, - success: false, - error: error.message, - total: 0, - images: [] - }; - } -} - -async function intelligentTagMatching(tagObjs, settings) { - if (!tagObjs || tagObjs.length === 0) { - throw new Error('没有可用的标签'); - } - - const allImages = new Map(); - - for (let i = 0; i < tagObjs.length; i++) { - if (!isActive()) { - throw new Error('功能已禁用'); - } - - const tagObj = tagObjs[i]; - const isCustom = tagObj.category === 'custom' ? '[自定义]' : ''; - updateProgressText(`搜索 ${i + 1}/${tagObjs.length}: ${isCustom}${tagObj.tag} (权重${tagObj.weight})`); - const result = await searchSingleTag(tagObj, settings.category, settings.purity, settings.bgMode); - if (result.success) { - result.images.forEach(img => { - if (!allImages.has(img.id)) { - allImages.set(img.id, { - ...img, - matchedTags: [tagObj], - weightedScore: tagObj.weight - }); - } else { - const existingImg = allImages.get(img.id); - existingImg.matchedTags.push(tagObj); - existingImg.weightedScore += tagObj.weight; - } - }); - } - if (i < tagObjs.length - 1) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - const allImagesArray = Array.from(allImages.values()); - if (allImagesArray.length === 0) { - throw new Error('所有标签都没有找到匹配的图片'); - } - - allImagesArray.sort((a, b) => { - if (b.weightedScore !== a.weightedScore) { - return b.weightedScore - a.weightedScore; - } - return b.favorites - a.favorites; - }); - - const maxWeightedScore = allImagesArray[0].weightedScore; - const bestMatches = allImagesArray.filter(img => img.weightedScore === maxWeightedScore); - const randomIndex = Math.floor(Math.random() * bestMatches.length); - - return bestMatches[randomIndex]; -} - -function applyMessageStyling() { - const mesElements = document.querySelectorAll('#chat .mes:not([data-wallhaven-styled])'); - mesElements.forEach(mes => { - mes.style.cssText += ` - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - background-color: transparent !important; - box-shadow: none !important; - position: relative !important; - z-index: 1002 !important; - `; - mes.setAttribute('data-wallhaven-styled', 'true'); - }); - - const mesTextElements = document.querySelectorAll('#chat .mes_text:not([data-wallhaven-text-styled])'); - mesTextElements.forEach(mesText => { - mesText.style.cssText += ` - text-shadow: rgba(0, 0, 0, 0.8) 1px 1px 2px !important; - color: inherit !important; - position: relative !important; - z-index: 1003 !important; - `; - mesText.setAttribute('data-wallhaven-text-styled', 'true'); - }); - - const messageElements = document.querySelectorAll('#chat .mes, #chat .mes_text, #chat .name, #chat .mes_img, #chat .mes_avatar, #chat .mes_btn'); - messageElements.forEach(element => { - if (element && !element.hasAttribute('data-wallhaven-z-styled')) { - element.style.cssText += ` - position: relative !important; - z-index: 1002 !important; - `; - element.setAttribute('data-wallhaven-z-styled', 'true'); - } - }); -} - -function applyBackgroundToApp(imageUrl, settings) { - currentImageUrl = imageUrl; - currentSettings = { ...settings }; - lastScreenSize = getCurrentScreenSize(); - - const isSmallScreen = window.innerWidth <= 1000; - - if (isSmallScreen) { - const chatElement = document.getElementById('chat'); - if (!chatElement) return; - - const bgId = 'wallhaven-mobile-background'; - const overlayId = 'wallhaven-mobile-overlay'; - - document.querySelectorAll('[id^="wallhaven-"]').forEach(el => el.remove()); - - let topOffset = 0; - const rightNavHolder = document.getElementById('rightNavHolder'); - if (rightNavHolder) { - const rect = rightNavHolder.getBoundingClientRect(); - topOffset = rect.bottom; - } else { - topOffset = 50; - } - - let backgroundContainer = document.getElementById(bgId); - let overlay = document.getElementById(overlayId); - - if (!backgroundContainer) { - backgroundContainer = document.createElement('div'); - backgroundContainer.id = bgId; - backgroundContainer.style.cssText = ` - position: fixed !important; - top: ${topOffset}px !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - width: 100vw !important; - height: calc(100vh - ${topOffset}px) !important; - background-size: 100% auto !important; - background-position: top center !important; - background-repeat: no-repeat !important; - z-index: -1 !important; - pointer-events: none !important; - overflow: hidden !important; - `; - document.body.appendChild(backgroundContainer); - } - - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = overlayId; - overlay.style.cssText = ` - position: fixed !important; - top: ${topOffset}px !important; - left: 0 !important; - right: 0 !important; - bottom: 0 !important; - width: 100vw !important; - height: calc(100vh - ${topOffset}px) !important; - background-color: rgba(0, 0, 0, ${settings.opacity}) !important; - z-index: 0 !important; - pointer-events: none !important; - overflow: hidden !important; - `; - document.body.appendChild(overlay); - } - - backgroundContainer.style.backgroundImage = `url("${imageUrl}")`; - overlay.style.backgroundColor = `rgba(0, 0, 0, ${settings.opacity})`; - - backgroundContainer.style.top = `${topOffset}px`; - backgroundContainer.style.height = `calc(100vh - ${topOffset}px)`; - overlay.style.top = `${topOffset}px`; - overlay.style.height = `calc(100vh - ${topOffset}px)`; - - if (chatElement) { - chatElement.style.cssText += ` - background-color: transparent !important; - background-image: none !important; - background: transparent !important; - position: relative !important; - z-index: 1 !important; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - `; - } - - applyMessageStyling(); - - } else { - const targetContainer = document.getElementById('expression-wrapper'); - if (!targetContainer) return; - - const bgId = 'wallhaven-app-background'; - const overlayId = 'wallhaven-app-overlay'; - - let backgroundContainer = document.getElementById(bgId); - let overlay = document.getElementById(overlayId); - - if (!backgroundContainer) { - backgroundContainer = document.createElement('div'); - backgroundContainer.id = bgId; - backgroundContainer.style.cssText = ` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-size: 100% auto; - background-position: top center; - background-repeat: no-repeat; - z-index: 1; - pointer-events: none; - `; - targetContainer.insertBefore(backgroundContainer, targetContainer.firstChild); - } - - if (!overlay) { - overlay = document.createElement('div'); - overlay.id = overlayId; - overlay.style.cssText = ` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, ${settings.opacity}); - z-index: 2; - pointer-events: none; - `; - targetContainer.insertBefore(overlay, targetContainer.firstChild); - } - - backgroundContainer.style.backgroundImage = `url("${imageUrl}")`; - overlay.style.backgroundColor = `rgba(0, 0, 0, ${settings.opacity})`; - - targetContainer.style.position = 'relative'; - - const chatElement = document.getElementById('chat'); - if (chatElement) { - chatElement.style.cssText += ` - background-color: transparent !important; - background-image: none !important; - background: transparent !important; - position: relative; - z-index: 3; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; - box-shadow: none !important; - border: none !important; - text-shadow: none !important; - opacity: 1 !important; - `; - } - applyMessageStyling(); - } -} - -function isMessageComplete(messageElement) { - const regenerateBtn = messageElement.querySelector('.mes_regenerate'); - const editBtn = messageElement.querySelector('.mes_edit'); - const hasButtons = regenerateBtn || editBtn; - - const mesText = messageElement.querySelector('.mes_text'); - const hasContent = mesText && mesText.textContent.trim().length > 0; - - const hasStreamingIndicator = messageElement.querySelector('.typing_indicator') || - messageElement.querySelector('.mes_loading') || - messageElement.classList.contains('streaming'); - - return hasButtons && hasContent && !hasStreamingIndicator; -} - -function getContentHash(text) { - let hash = 0; - if (text.length === 0) return hash; - for (let i = 0; i < text.length; i++) { - const char = text.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return hash.toString(); -} - -function shouldProcessMessage(messageId, messageText) { - const contentHash = getContentHash(messageText); - const storedHash = processedMessages.get(messageId); - return !storedHash || storedHash !== contentHash; -} - -function markMessageProcessed(messageId, messageText) { - const contentHash = getContentHash(messageText); - processedMessages.set(messageId, contentHash); -} - -async function handleAIMessage(data) { - if (!isActive() || isProcessing) return; - - try { - isProcessing = true; - - const messageId = data.messageId || data; - if (!messageId) return; - - const messageElement = document.querySelector(`div.mes[mesid="${messageId}"]`); - if (!messageElement || messageElement.classList.contains('is_user')) return; - - let retryCount = 0; - const maxRetries = 10; - - while (retryCount < maxRetries) { - if (isMessageComplete(messageElement)) { - break; - } - retryCount++; - await new Promise(resolve => setTimeout(resolve, 1000)); - - if (!isActive()) return; - } - - const mesText = messageElement.querySelector('.mes_text'); - if (!mesText) return; - - const messageText = mesText.textContent || ''; - if (!messageText.trim() || messageText.length < 10) return; - - if (!shouldProcessMessage(messageId, messageText)) { - return; - } - - markMessageProcessed(messageId, messageText); - - const settings = getWallhavenSettings(); - - showProgressInMessageHeader(messageElement, '提取标签中...'); - - const result = extractTagsFromText(messageText, settings.bgMode); - if (result.tags.length === 0) { - updateProgressText('未提取到标签'); - setTimeout(removeProgressFromMessageHeader, 2000); - return; - } - - if (!isActive()) return; - - const orientation = isLandscapeOrientation() ? '横屏' : '竖屏'; - const modeText = settings.bgMode ? '背景' : '角色'; - const totalWeight = result.tags.reduce((sum, tagObj) => sum + tagObj.weight, 0); - const customCount = result.tags.filter(t => t.category === 'custom').length; - updateProgressText(`${orientation}${modeText}:提取到 ${result.tags.length} 个标签 (自定义${customCount}个,总权重${totalWeight})`); - await new Promise(resolve => setTimeout(resolve, 500)); - - if (!isActive()) return; - - const selectedImage = await intelligentTagMatching(result.tags, settings); - - if (!isActive()) return; - - updateProgressText('应用背景中...'); - - const imageUrl = `https://wallhaven.velure.top/?url=${encodeURIComponent(selectedImage.path)}`; - - applyBackgroundToApp(imageUrl, settings); - - const coreTagsCount = selectedImage.matchedTags.filter(t => t.weight >= 2).length; - const customMatchCount = selectedImage.matchedTags.filter(t => t.category === 'custom').length; - updateProgressText(`${modeText}配图完成! 核心匹配${coreTagsCount}个 自定义${customMatchCount}个 权重${selectedImage.weightedScore}`); - setTimeout(removeProgressFromMessageHeader, 2000); - - } catch (error) { - updateProgressText(`配图失败: ${error.message.length > 20 ? error.message.substring(0, 20) + '...' : error.message}`); - setTimeout(removeProgressFromMessageHeader, 3000); - } finally { - isProcessing = false; - } -} - -function updateSettingsControls() { - const settings = getWallhavenSettings(); - $('#wallhaven_enabled').prop('checked', settings.enabled); - $('#wallhaven_bg_mode').prop('checked', settings.bgMode); - $('#wallhaven_category').val(settings.category); - $('#wallhaven_purity').val(settings.purity); - $('#wallhaven_opacity').val(settings.opacity); - $('#wallhaven_opacity_value').text(Math.round(settings.opacity * 100) + '%'); - - // 控制后续设置的显示/隐藏 - const settingsContainer = $('#wallhaven_settings_container'); - if (settings.enabled) { - settingsContainer.show(); - } else { - settingsContainer.hide(); - } - - renderCustomTagsList(); -} - -function initSettingsEvents() { - $('#wallhaven_enabled').off('change').on('change', function() { - if (!window.isXiaobaixEnabled) return; - - const settings = getWallhavenSettings(); - const wasEnabled = settings.enabled; - settings.enabled = $(this).prop('checked'); - saveSettingsDebounced(); - - // 控制后续设置的显示/隐藏 - const settingsContainer = $('#wallhaven_settings_container'); - if (settings.enabled) { - settingsContainer.show(); - } else { - settingsContainer.hide(); - } - - if (settings.enabled && !wasEnabled) { - bindMessageHandlers(); - } else if (!settings.enabled && wasEnabled) { - clearBackgroundState(); - removeProgressFromMessageHeader(); - processedMessages.clear(); - isProcessing = false; - unbindMessageHandlers(); - } - }); - - $('#wallhaven_bg_mode').off('change').on('change', function() { - if (!window.isXiaobaixEnabled) return; - const settings = getWallhavenSettings(); - settings.bgMode = $(this).prop('checked'); - saveSettingsDebounced(); - }); - - $('#wallhaven_category').off('change').on('change', function() { - if (!window.isXiaobaixEnabled) return; - const settings = getWallhavenSettings(); - settings.category = $(this).val(); - saveSettingsDebounced(); - }); - - $('#wallhaven_purity').off('change').on('change', function() { - if (!window.isXiaobaixEnabled) return; - const settings = getWallhavenSettings(); - settings.purity = $(this).val(); - saveSettingsDebounced(); - }); - - $('#wallhaven_opacity').off('input').on('input', function() { - if (!window.isXiaobaixEnabled) return; - const settings = getWallhavenSettings(); - settings.opacity = parseFloat($(this).val()); - $('#wallhaven_opacity_value').text(Math.round(settings.opacity * 100) + '%'); - $('#wallhaven-app-overlay, #wallhaven-chat-overlay').css('background-color', `rgba(0, 0, 0, ${settings.opacity})`); - saveSettingsDebounced(); - }); - - $('#wallhaven_add_custom_tag').off('click').on('click', function() { - if (!window.isXiaobaixEnabled) return; - const input = document.getElementById('wallhaven_custom_tag_input'); - const tag = input.value.trim(); - if (tag) { - if (addCustomTag(tag)) { - input.value = ''; - } else { - input.style.borderColor = '#ff6b6b'; - setTimeout(() => { - input.style.borderColor = ''; - }, 1000); - } - } - }); - - $('#wallhaven_custom_tag_input').off('keypress').on('keypress', function(e) { - if (!window.isXiaobaixEnabled) return; - if (e.which === 13) { - $('#wallhaven_add_custom_tag').click(); - } - }); -} - -function bindMessageHandlers() { - messageEvents.cleanup(); - - messageEvents.on(event_types.MESSAGE_RECEIVED, handleAIMessage); - if (event_types.MESSAGE_SWIPED) { - messageEvents.on(event_types.MESSAGE_SWIPED, handleAIMessage); - } - if (event_types.MESSAGE_EDITED) { - messageEvents.on(event_types.MESSAGE_EDITED, handleAIMessage); - } - if (event_types.MESSAGE_UPDATED) { - messageEvents.on(event_types.MESSAGE_UPDATED, handleAIMessage); - } -} - -function unbindMessageHandlers() { - messageEvents.cleanup(); -} - -function handleGlobalStateChange(event) { - const globalEnabled = event.detail.enabled; - - const wallhavenControls = [ - 'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category', - 'wallhaven_purity', 'wallhaven_opacity', 'wallhaven_custom_tag_input', - 'wallhaven_add_custom_tag' - ]; - - wallhavenControls.forEach(id => { - $(`#${id}`).prop('disabled', !globalEnabled).toggleClass('disabled-control', !globalEnabled); - }); - - if (globalEnabled) { - updateSettingsControls(); - initSettingsEvents(); - - if (isActive()) { - bindMessageHandlers(); - } - } else { - clearBackgroundState(); - removeProgressFromMessageHeader(); - processedMessages.clear(); - isProcessing = false; - - unbindMessageHandlers(); - - $('#wallhaven_enabled, #wallhaven_bg_mode, #wallhaven_category, #wallhaven_purity, #wallhaven_opacity, #wallhaven_add_custom_tag').off(); - $('#wallhaven_custom_tag_input').off(); - } -} - -function handleChatChanged() { - processedMessages.clear(); - clearBackgroundState(); - removeProgressFromMessageHeader(); - isProcessing = false; -} - -function initWallhavenBackground() { - const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : true; - - const wallhavenControls = [ - 'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category', - 'wallhaven_purity', 'wallhaven_opacity', 'wallhaven_custom_tag_input', - 'wallhaven_add_custom_tag' - ]; - - wallhavenControls.forEach(id => { - $(`#${id}`).prop('disabled', !globalEnabled).toggleClass('disabled-control', !globalEnabled); - }); - - if (globalEnabled) { - updateSettingsControls(); - initSettingsEvents(); - - if (isActive()) { - bindMessageHandlers(); - } - } - - document.addEventListener('xiaobaixEnabledChanged', handleGlobalStateChange); - globalEvents.on(event_types.CHAT_CHANGED, handleChatChanged); - window.addEventListener('resize', handleWindowResize); - - lastScreenSize = getCurrentScreenSize(); - - return { cleanup }; -} - -function cleanup() { - messageEvents.cleanup(); - globalEvents.cleanup(); - document.removeEventListener('xiaobaixEnabledChanged', handleGlobalStateChange); - window.removeEventListener('resize', handleWindowResize); - - clearBackgroundState(); - removeProgressFromMessageHeader(); - - isProcessing = false; - processedMessages.clear(); - currentProgressButton = null; - currentImageUrl = null; - currentSettings = null; -} - -export { initWallhavenBackground }; +import { extension_settings, getContext } from "../../../../extensions.js"; +import { saveSettingsDebounced } from "../../../../../script.js"; +import { EXT_ID } from "../core/constants.js"; +import { createModuleEvents, event_types } from "../core/event-manager.js"; + +const MODULE_NAME = "wallhavenBackground"; +const messageEvents = createModuleEvents('wallhaven:messages'); +const globalEvents = createModuleEvents('wallhaven'); + +const defaultSettings = { + enabled: false, + bgMode: false, + category: "010", + purity: "100", + opacity: 0.3, + customTags: [] +}; + +const tagWeights = { + custom: 3.0, + characters: 3, + locations: 3, + nsfw_actions: 3, + intimate_settings: 3, + poses: 2, + clothing: 2, + nsfw_body_parts: 2, + activities: 2, + expressions: 1.5, + body_features: 1.5, + nsfw_states: 1.5, + fetish_categories: 1.5, + clothing_states: 1.5, + nsfw_descriptions: 1.5, + colors: 1, + objects: 1, + weather_time: 1, + styles: 1, + emotional_states: 1, + romance_keywords: 1, + body_modifications: 1, + nsfw_sounds: 1 +}; + +const wallhavenTags = { + characters: { + // 基础人称 + "女孩": "anime girl", "少女": "anime girl", "女性": "woman", "女人": "woman", + "男孩": "boy", "少年": "boy", "男性": "man", "男人": "man", + "美女": "beautiful woman", "帅哥": "handsome man", "女生": "girl", "男生": "boy", + + // 职业角色 + "女仆": "maid", "侍女": "maid", "佣人": "maid", "管家": "butler", + "秘书": "secretary", "助理": "assistant", "下属": "subordinate", + "老板": "boss", "上司": "superior", "领导": "leader", "经理": "manager", + "同事": "colleague", "伙伴": "partner", "搭档": "partner", + "客户": "client", "顾客": "customer", "委托人": "client", + + // 学校相关 + "学生": "student", "学员": "student", "同学": "student", + "男同学": "male student", "女同学": "schoolgirl", "女学生": "schoolgirl", "男学生": "male student", + "老师": "teacher", "教师": "teacher", "先生": "teacher", "导师": "mentor", + "校长": "principal", "教授": "professor", "讲师": "lecturer", + "学姐": "senior student", "学妹": "junior student", "学长": "senior student", "学弟": "junior student", + "班长": "class president", "社长": "club president", + + // 医疗相关 + "护士": "nurse", "白衣天使": "nurse", "医护": "nurse", + "医生": "doctor", "大夫": "doctor", "医师": "physician", + "病人": "patient", "患者": "patient", + + // 家庭关系 + "母亲": "mother", "妈妈": "mother", "母": "mother", "妈": "mother", + "父亲": "father", "爸爸": "father", "父": "father", "爸": "father", + "姐姐": "sister", "妹妹": "sister", "哥哥": "brother", "弟弟": "brother", + "女儿": "daughter", "闺女": "daughter", "儿子": "son", + "妻子": "wife", "老婆": "wife", "丈夫": "husband", "老公": "husband", + "岳母": "mother-in-law", "婆婆": "mother-in-law", "丈母娘": "mother-in-law", + "阿姨": "aunt", "叔叔": "uncle", "表姐": "cousin", "表妹": "cousin", + "邻居": "neighbor", "房东": "landlord", "租客": "tenant", + + // 特殊身份 + "公主": "princess", "殿下": "princess", "王女": "princess", + "王子": "prince", "王子殿下": "prince", "皇子": "prince", + "女王": "queen", "国王": "king", "皇帝": "emperor", "皇后": "empress", + "贵族": "noble", "富家千金": "rich girl", "大小姐": "young lady", + "平民": "commoner", "村民": "villager", "市民": "citizen", + + // 二次元角色 + "猫娘": "catgirl", "猫女": "catgirl", "猫咪女孩": "catgirl", + "狐娘": "fox girl", "狐狸女孩": "fox girl", "狐仙": "fox girl", + "兔娘": "bunny girl", "兔女郎": "bunny girl", "兔子女孩": "bunny girl", + "狼娘": "wolf girl", "犬娘": "dog girl", "龙娘": "dragon girl", + "魔女": "witch", "女巫": "witch", "巫女": "witch", "魔法少女": "magical girl", + "天使": "angel", "小天使": "angel", "堕天使": "fallen angel", + "恶魔": "demon", "魅魔": "demon", "小恶魔": "demon", + "精灵": "elf", "森林精灵": "elf", "暗精灵": "dark elf", + "吸血鬼": "vampire", "血族": "vampire", "僵尸": "zombie", + "人偶": "doll", "机器人": "android", "人造人": "artificial human", + "外星人": "alien", "异世界人": "otherworld person", + + // 职业类型 + "忍者": "ninja", "女忍": "ninja", "武士": "warrior", "剑士": "swordsman", + "骑士": "knight", "圣骑士": "paladin", "战士": "warrior", + "法师": "wizard", "魔法师": "wizard", "术士": "sorcerer", + "牧师": "priest", "修女": "nun", "尼姑": "nun", + "盗贼": "thief", "刺客": "assassin", "间谍": "spy", + "雇佣兵": "mercenary", "佣兵": "mercenary", "赏金猎人": "bounty hunter", + + // 现代职业 + "警察": "police", "警官": "police", "侦探": "detective", "探长": "detective", + "消防员": "firefighter", "消防": "firefighter", + "军人": "soldier", "士兵": "soldier", "特种兵": "special forces", + "飞行员": "pilot", "船长": "captain", "司机": "driver", + "厨师": "chef", "料理师": "chef", "服务员": "waitress", + "调酒师": "bartender", "咖啡师": "barista", + "艺术家": "artist", "画家": "artist", "雕塑家": "sculptor", + "音乐家": "musician", "歌手": "singer", "偶像": "idol", + "演员": "actress", "模特": "model", "舞者": "dancer", + "作家": "writer", "记者": "journalist", "编辑": "editor", + "科学家": "scientist", "研究员": "scientist", "博士": "doctor", + "程序员": "programmer", "工程师": "engineer", "设计师": "designer", + "商人": "businessman", "企业家": "entrepreneur", "投资者": "investor", + + // 特殊关系 + "新娘": "bride", "新嫁娘": "bride", "新郎": "groom", + "前女友": "ex-girlfriend", "前男友": "ex-boyfriend", + "青梅竹马": "childhood friend", "闺蜜": "best friend", "好友": "friend", + "对手": "rival", "敌人": "enemy", "仇人": "enemy", + "陌生人": "stranger", "路人": "passerby", "访客": "visitor" + }, + + clothing: { + // 裙装类 + "连衣裙": "dress", "裙子": "dress", "长裙": "long dress", "短裙": "short dress", + "迷你裙": "mini dress", "中裙": "midi dress", "蓬蓬裙": "puffy dress", + "紧身裙": "tight dress", "A字裙": "a-line dress", "包臀裙": "pencil skirt", + "百褶裙": "pleated skirt", "伞裙": "circle skirt", "吊带裙": "slip dress", + + // 制服类 + "校服": "school uniform", "制服": "uniform", "学生服": "school uniform", + "女仆装": "maid outfit", "女仆服": "maid outfit", + "护士服": "nurse outfit", "白大褂": "nurse outfit", + "警服": "police uniform", "军装": "military uniform", + "空姐服": "flight attendant uniform", "服务员服": "waitress uniform", + "OL装": "office lady outfit", "职业装": "business attire", + + // 传统服装 + "和服": "kimono", "浴衣": "kimono", "振袖": "kimono", + "旗袍": "qipao", "中式服装": "chinese dress", "汉服": "hanfu", + "洛丽塔": "lolita dress", "哥特装": "gothic dress", + + // 特殊场合 + "婚纱": "wedding dress", "新娘装": "wedding dress", "礼服": "evening gown", + "晚礼服": "evening dress", "舞会裙": "ball gown", "宴会服": "party dress", + "演出服": "performance outfit", "舞台装": "stage outfit", + + // 休闲装 + "T恤": "t-shirt", "衬衫": "shirt", "衬衣": "blouse", + "吊带": "tank top", "背心": "vest", "卫衣": "hoodie", + "夹克": "jacket", "外套": "coat", "风衣": "trench coat", + "毛衣": "sweater", "针织衫": "knit sweater", "开衫": "cardigan", + + // 裤装 + "牛仔裤": "jeans", "裤子": "pants", "短裤": "shorts", + "热裤": "hot pants", "紧身裤": "leggings", "喇叭裤": "flare pants", + "西装裤": "suit pants", "休闲裤": "casual pants", + + // 运动装 + "运动服": "sportswear", "瑜伽服": "yoga outfit", "健身服": "gym wear", + "田径服": "track suit", "篮球服": "basketball uniform", + + // 睡衣家居 + "睡衣": "pajamas", "居家服": "pajamas", "睡袍": "nightgown", + "浴袍": "bathrobe", "晨袍": "morning robe", "家居服": "loungewear", + + // 泳装 + "泳装": "swimsuit", "泳衣": "swimsuit", "比基尼": "bikini", + "连体泳衣": "one-piece swimsuit", "三点式": "bikini", + + // 内衣 + "内衣": "lingerie", "胸罩": "bra", "内裤": "panties", + "文胸": "bra", "胖次": "panties", "三角裤": "briefs", + "平角裤": "boxers", "内衣套装": "lingerie set", + "情趣内衣": "sexy lingerie", "蕾丝内衣": "lace lingerie", + + // 袜类 + "丝袜": "stockings", "长筒袜": "stockings", "连裤袜": "pantyhose", + "过膝袜": "thigh highs", "短袜": "ankle socks", "船袜": "no-show socks", + "网袜": "fishnet stockings", "白丝": "white stockings", "黑丝": "black stockings", + + // 鞋类 + "高跟鞋": "high heels", "靴子": "boots", "凉鞋": "sandals", + "平底鞋": "flats", "帆布鞋": "canvas shoes", "运动鞋": "sneakers", + "马丁靴": "combat boots", "长靴": "knee boots", "短靴": "ankle boots", + "拖鞋": "slippers", "人字拖": "flip flops", + + // 配饰 + "手套": "gloves", "帽子": "hat", "眼镜": "glasses", + "太阳镜": "sunglasses", "发带": "headband", "头巾": "headscarf", + "围巾": "scarf", "披肩": "shawl", "领带": "tie", + "蝴蝶结": "bow tie", "腰带": "belt", "围裙": "apron", + + // 首饰 + "项链": "necklace", "耳环": "earrings", "戒指": "ring", + "手镯": "bracelet", "脚链": "anklet", "发饰": "hair accessory", + "胸针": "brooch", "手表": "watch" + }, + + body_features: { + // 发型 + "长发": "long hair", "短发": "short hair", "中发": "medium hair", + "马尾": "ponytail", "双马尾": "twintails", "侧马尾": "side ponytail", + "丸子头": "bun", "包子头": "bun", "公主头": "half updo", + "刘海": "bangs", "齐刘海": "straight bangs", "斜刘海": "side bangs", + "卷发": "curly hair", "直发": "straight hair", "波浪发": "wavy hair", + "盘发": "updo", "编发": "braided hair", "麻花辫": "braids", + "单边辫": "side braid", "双辫": "twin braids", + "蓬松发": "voluminous hair", "顺滑发": "silky hair", + + // 发色 + "黑发": "black hair", "金发": "blonde hair", "棕发": "brown hair", + "白发": "white hair", "银发": "silver hair", "红发": "red hair", + "蓝发": "blue hair", "粉发": "pink hair", "紫发": "purple hair", + "绿发": "green hair", "橙发": "orange hair", "灰发": "gray hair", + "彩虹发": "rainbow hair", "渐变发": "gradient hair", + + // 身材 + "高个": "tall", "矮个": "short", "娇小": "petite", "高挑": "tall and slender", + "苗条": "slim", "纤细": "slim", "瘦": "thin", "骨感": "skinny", + "丰满": "curvy", "饱满": "voluptuous", "肉感": "plump", + "匀称": "well-proportioned", "性感": "sexy", "优美": "graceful", + + // 胸部 + "大胸": "large breasts", "巨乳": "huge breasts", "丰满": "large breasts", + "小胸": "small breasts", "贫乳": "small breasts", "平胸": "flat chest", + "挺拔": "perky", "坚挺": "firm", "饱满": "full", + "柔软": "soft", "弹性": "bouncy", "深沟": "cleavage", + + // 腿部 + "美腿": "beautiful legs", "长腿": "long legs", "细腿": "slender legs", + "修长": "slender", "笔直": "straight", "匀称": "shapely", + "大腿": "thighs", "小腿": "calves", "脚踝": "ankles", + + // 皮肤 + "白皙": "fair", "雪白": "snow white", "透白": "translucent", + "古铜": "tanned", "小麦色": "wheat colored", "健康": "healthy", + "光滑": "smooth", "细腻": "delicate", "粗糙": "rough", + "红润": "rosy", "苍白": "pale", "有光泽": "glowing", + + // 眼睛 + "大眼": "big eyes", "小眼": "small eyes", "圆眼": "round eyes", + "细长眼": "narrow eyes", "杏眼": "almond eyes", "桃花眼": "peach blossom eyes", + "双眼皮": "double eyelids", "单眼皮": "single eyelids", + "长睫毛": "long eyelashes", "浓睫毛": "thick eyelashes", + + // 特殊特征 + "猫耳": "cat ears", "狐耳": "fox ears", "兔耳": "bunny ears", + "狼耳": "wolf ears", "犬耳": "dog ears", "精灵耳": "elf ears", + "翅膀": "wings", "天使翅膀": "angel wings", "恶魔翅膀": "demon wings", + "尾巴": "tail", "猫尾": "cat tail", "狐尾": "fox tail", + "角": "horns", "恶魔角": "demon horns", "独角": "unicorn horn", + + // 体格 + "肌肉": "muscular", "强壮": "muscular", "健美": "athletic", + "结实": "sturdy", "精瘦": "lean", "厚实": "solid", + "柔弱": "delicate", "纤弱": "fragile", "娇弱": "frail", + + // 面部特征 + "圆脸": "round face", "瓜子脸": "oval face", "方脸": "square face", + "鹅蛋脸": "oval face", "心形脸": "heart-shaped face", + "高鼻梁": "high nose bridge", "小鼻子": "small nose", + "厚嘴唇": "thick lips", "薄嘴唇": "thin lips", "樱桃嘴": "cherry lips", + "尖下巴": "pointed chin", "圆下巴": "round chin", + "酒窝": "dimples", "笑容": "smile", "梨涡": "dimples", + + // 其他 + "胡子": "beard", "胡须": "mustache", "络腮胡": "full beard", + "光头": "bald", "秃头": "bald", "寸头": "buzz cut", + "疤痕": "scar", "纹身": "tattoo", "胎记": "birthmark", + "雀斑": "freckles", "痣": "mole", "美人痣": "beauty mark", + "虎牙": "fangs", "小虎牙": "small fangs" + }, + + expressions: { + // 快乐情绪 + "微笑": "smile", "笑": "smile", "开心": "happy", "高兴": "happy", + "大笑": "laughing", "窃笑": "giggling", "傻笑": "silly smile", + "甜笑": "sweet smile", "温和笑": "gentle smile", "灿烂笑": "bright smile", + "兴奋": "excited", "激动": "excited", "愉快": "cheerful", + "欣喜": "delighted", "狂喜": "ecstatic", "满意": "satisfied", + + // 悲伤情绪 + "伤心": "sad", "难过": "sad", "哭": "crying", "流泪": "tears", + "大哭": "sobbing", "抽泣": "sniffling", "眼泪汪汪": "teary eyes", + "悲伤": "sorrowful", "沮丧": "depressed", "失落": "disappointed", + "绝望": "despair", "痛苦": "painful", "心碎": "heartbroken", + + // 愤怒情绪 + "生气": "angry", "愤怒": "angry", "恼火": "angry", + "暴怒": "furious", "发火": "mad", "气愤": "indignant", + "不满": "dissatisfied", "抱怨": "complaining", "怨恨": "resentful", + + // 害羞情绪 + "害羞": "shy", "脸红": "blushing", "羞涩": "shy", + "害臊": "bashful", "腼腆": "timid", "不好意思": "embarrassed", + "羞耻": "ashamed", "窘迫": "flustered", "局促": "awkward", + + // 惊讶情绪 + "惊讶": "surprised", "吃惊": "surprised", "震惊": "shocked", + "惊愕": "astonished", "惊恐": "horrified", "目瞪口呆": "stunned", + "困惑": "confused", "疑惑": "puzzled", "迷茫": "bewildered", + + // 温柔情绪 + "温柔": "gentle", "柔和": "gentle", "亲切": "gentle", + "慈祥": "kind", "和善": "friendly", "温暖": "warm", + "关爱": "caring", "怜爱": "tender", "宠溺": "doting", + + // 严肃情绪 + "严肃": "serious", "认真": "serious", "冷静": "calm", + "严厉": "stern", "冷漠": "indifferent", "无表情": "expressionless", + "冷酷": "cold", "淡漠": "aloof", "疏远": "distant", + + // 疲倦情绪 + "困": "sleepy", "累": "tired", "疲倦": "tired", + "疲惫": "exhausted", "困倦": "drowsy", "无精打采": "listless", + "虚弱": "weak", "萎靡": "dispirited", "懒散": "lazy", + + // 其他情绪 + "紧张": "nervous", "担心": "worried", "焦虑": "anxious", + "恐惧": "fearful", "害怕": "scared", "胆怯": "timid", + "自信": "confident", "骄傲": "proud", "得意": "smug", + "傲慢": "arrogant", "轻蔑": "contemptuous", "不屑": "disdainful", + "好奇": "curious", "感兴趣": "interested", "专注": "focused", + "集中": "concentrated", "沉思": "contemplating", "思考": "thinking", + "无聊": "bored", "厌倦": "tired of", "烦躁": "irritated", + "期待": "expectant", "渴望": "longing", "向往": "yearning" + }, + + poses: { + // 基础姿势 + "站着": "standing", "站立": "standing", "直立": "upright", + "坐着": "sitting", "坐下": "sitting", "端坐": "sitting properly", + "躺着": "lying", "躺下": "lying", "平躺": "lying flat", + "跪着": "kneeling", "下跪": "kneeling", "跪坐": "seiza", + "蹲着": "squatting", "蹲下": "crouching", "半蹲": "half squat", + + // 动作姿势 + "走路": "walking", "行走": "walking", "漫步": "strolling", + "跑步": "running", "奔跑": "running", "疾跑": "sprinting", + "跳跃": "jumping", "蹦跳": "hopping", "飞跃": "leaping", + "跳舞": "dancing", "舞蹈": "dancing", "旋转": "spinning", + "伸展": "stretching", "弯腰": "bending", "下腰": "backbend", + + // 手部动作 + "举手": "arms up", "抬手": "arms up", "双手举起": "both arms up", + "伸手": "reaching out", "指向": "pointing", "挥手": "waving", + "鼓掌": "clapping", "握拳": "clenched fist", "比心": "heart gesture", + "捂脸": "covering face", "托腮": "chin rest", "撑头": "head rest", + + // 互动姿势 + "拥抱": "hugging", "抱着": "hugging", "搂抱": "embracing", + "牵手": "holding hands", "握手": "handshake", "搀扶": "supporting", + "背着": "carrying on back", "抱起": "lifting up", "搂腰": "arm around waist", + + // 生活姿势 + "睡觉": "sleeping", "熟睡": "sleeping", "打盹": "napping", + "看书": "reading", "阅读": "reading", "翻书": "turning pages", + "写字": "writing", "书写": "writing", "画画": "drawing", + "工作": "working", "学习": "studying", "思考": "thinking", + "做作业": "homework", "写作业": "homework", "考试": "taking exam", + + // 运动姿势 + "游泳": "swimming", "潜水": "diving", "跳水": "diving", + "爬山": "climbing", "攀登": "climbing", "攀岩": "rock climbing", + "骑车": "cycling", "开车": "driving", "骑马": "horseback riding", + "滑雪": "skiing", "滑冰": "ice skating", "溜冰": "skating", + "健身": "exercising", "瑜伽": "yoga", "拉伸": "stretching", + + // 战斗姿势 + "战斗": "fighting", "格斗": "combat", "对战": "battle", + "攻击": "attacking", "防御": "defending", "出拳": "punching", + "踢腿": "kicking", "挥剑": "sword swinging", "射箭": "archery", + + // 表演姿势 + "唱歌": "singing", "演奏": "playing instrument", "表演": "performing", + "朗诵": "reciting", "演讲": "giving speech", "主持": "hosting", + + // 特殊姿势 + "冥想": "meditation", "祈祷": "praying", "许愿": "making wish", + "仰望": "looking up", "俯视": "looking down", "回首": "looking back", + "侧身": "side view", "背影": "back view", "正面": "front view", + "倚靠": "leaning", "靠墙": "leaning against wall", "趴着": "lying on stomach" + }, + + locations: { + // 居住场所 + "卧室": "bedroom", "房间": "bedroom", "寝室": "bedroom", + "客厅": "living room", "起居室": "living room", "大厅": "hall", + "厨房": "kitchen", "灶间": "kitchen", "餐厅": "dining room", + "浴室": "bathroom", "洗手间": "bathroom", "厕所": "toilet", + "阳台": "balcony", "露台": "terrace", "庭院": "courtyard", + "花园": "garden", "后院": "backyard", "前院": "front yard", + "地下室": "basement", "阁楼": "attic", "储藏室": "storage room", + + // 学校场所 + "教室": "classroom", "课堂": "classroom", "学校": "school", + "图书馆": "library", "书馆": "library", "阅览室": "reading room", + "实验室": "laboratory", "研究室": "laboratory", "计算机房": "computer room", + "体育馆": "gymnasium", "操场": "playground", "运动场": "sports field", + "食堂": "cafeteria", "宿舍": "dormitory", "社团室": "club room", + "校园": "campus", "校门": "school gate", "走廊": "corridor", + + // 工作场所 + "办公室": "office", "工作室": "office", "会议室": "meeting room", + "公司": "company", "企业": "corporation", "工厂": "factory", + "车间": "workshop", "仓库": "warehouse", "商店": "shop", + "超市": "supermarket", "商场": "shopping mall", "市场": "market", + "银行": "bank", "邮局": "post office", "政府": "government office", + + // 医疗场所 + "医院": "hospital", "诊所": "hospital", "急诊室": "emergency room", + "病房": "hospital room", "手术室": "operating room", "药房": "pharmacy", + + // 娱乐场所 + "咖啡厅": "cafe", "咖啡店": "cafe", "茶馆": "tea house", + "餐厅": "restaurant", "饭店": "restaurant", "快餐店": "fast food", + "酒吧": "bar", "夜店": "nightclub", "KTV": "karaoke", + "电影院": "cinema", "剧院": "theater", "音乐厅": "concert hall", + "游乐园": "amusement park", "动物园": "zoo", "水族馆": "aquarium", + "博物馆": "museum", "美术馆": "art gallery", "展览馆": "exhibition hall", + + // 自然场所 + "公园": "park", "广场": "square", "街道": "street", + "海边": "beach", "沙滩": "beach", "海滩": "beach", + "森林": "forest", "树林": "forest", "丛林": "jungle", + "山": "mountain", "高山": "mountain", "山顶": "mountain peak", + "湖边": "lake", "湖泊": "lake", "河边": "riverside", + "河流": "river", "溪流": "stream", "瀑布": "waterfall", + "草原": "grassland", "田野": "field", "农场": "farm", + "沙漠": "desert", "雪山": "snowy mountain", "冰川": "glacier", + + // 交通场所 + "火车站": "train station", "地铁站": "subway station", "公交站": "bus stop", + "机场": "airport", "港口": "port", "码头": "dock", + "停车场": "parking lot", "加油站": "gas station", + + // 宗教场所 + "教堂": "church", "寺庙": "temple", "清真寺": "mosque", + "神社": "shrine", "修道院": "monastery", + + // 特殊场所 + "城堡": "castle", "宫殿": "castle", "塔楼": "tower", + "桥": "bridge", "大桥": "bridge", "隧道": "tunnel", + "屋顶": "rooftop", "天台": "rooftop", "楼顶": "rooftop", + "地铁": "subway", "电梯": "elevator", "楼梯": "stairs", + "监狱": "prison", "法院": "courthouse", "警察局": "police station", + "温泉": "hot spring", "海岛": "island", "洞穴": "cave", + "废墟": "ruins", "遗迹": "ruins", "秘境": "secret place" + }, + + weather_time: { + // 天气 + "晴天": "sunny", "阳光": "sunny", "晴朗": "sunny", + "多云": "cloudy", "阴天": "cloudy", "乌云": "dark clouds", + "下雨": "rain", "雨天": "rainy", "雨": "rain", + "毛毛雨": "drizzle", "大雨": "heavy rain", "暴雨": "storm", + "雷雨": "thunderstorm", "闪电": "lightning", "打雷": "thunder", + "下雪": "snow", "雪天": "snowy", "雪": "snow", + "暴雪": "blizzard", "雪花": "snowflakes", "雪景": "snowy scene", + "雾": "fog", "薄雾": "mist", "浓雾": "thick fog", + "风": "wind", "微风": "breeze", "强风": "strong wind", + "台风": "typhoon", "龙卷风": "tornado", "沙尘暴": "sandstorm", + + // 时间 + "日出": "sunrise", "清晨": "morning", "早晨": "morning", + "上午": "morning", "中午": "noon", "下午": "afternoon", + "日落": "sunset", "黄昏": "sunset", "夕阳": "sunset", + "傍晚": "evening", "夜晚": "night", "晚上": "night", + "深夜": "night", "午夜": "midnight", "凌晨": "dawn", + "白天": "day", "日间": "day", "夜间": "night", + + // 季节 + "春天": "spring", "春季": "spring", "初春": "early spring", + "夏天": "summer", "夏季": "summer", "盛夏": "midsummer", + "秋天": "autumn", "秋季": "autumn", "深秋": "late autumn", + "冬天": "winter", "冬季": "winter", "隆冬": "midwinter", + + // 天象 + "月光": "moonlight", "满月": "full moon", "新月": "new moon", + "星空": "starry sky", "繁星": "stars", "银河": "milky way", + "彩虹": "rainbow", "双彩虹": "double rainbow", "流星": "meteor", + "日食": "solar eclipse", "月食": "lunar eclipse", "极光": "aurora", + + // 气候 + "炎热": "hot", "温暖": "warm", "凉爽": "cool", + "寒冷": "cold", "冰冷": "freezing", "严寒": "bitter cold", + "潮湿": "humid", "干燥": "dry", "闷热": "muggy" + }, + + colors: { + // 基础颜色 + "红色": "red", "红": "red", "朱红": "red", "深红": "dark red", + "粉色": "pink", "粉红": "pink", "粉": "pink", "浅粉": "light pink", + "橙色": "orange", "橘色": "orange", "橙": "orange", "橘红": "red orange", + "黄色": "yellow", "黄": "yellow", "金黄": "golden yellow", "柠檬黄": "lemon yellow", + "绿色": "green", "绿": "green", "翠绿": "emerald green", "深绿": "dark green", + "蓝色": "blue", "蓝": "blue", "天蓝": "sky blue", "深蓝": "dark blue", + "紫色": "purple", "紫": "purple", "紫罗兰": "violet", "深紫": "dark purple", + "黑色": "black", "黑": "black", "乌黑": "jet black", "深黑": "deep black", + "白色": "white", "白": "white", "洁白": "pure white", "雪白": "snow white", + "灰色": "gray", "灰": "gray", "银灰": "silver gray", "深灰": "dark gray", + "棕色": "brown", "褐色": "brown", "咖啡色": "coffee brown", "巧克力色": "chocolate", + + // 金属色 + "银色": "silver", "金色": "gold", "铜色": "copper", "青铜": "bronze", + "铂金": "platinum", "玫瑰金": "rose gold", + + // 特殊色彩 + "彩虹色": "rainbow", "渐变色": "gradient", "透明": "transparent", + "荧光": "fluorescent", "金属": "metallic", "珠光": "pearl", + "哑光": "matte", "亮光": "glossy", "闪光": "glitter" + }, + + objects: { + // 书籍文具 + "书": "book", "书本": "book", "图书": "book", "小说": "novel", + "教科书": "textbook", "字典": "dictionary", "杂志": "magazine", + "笔": "pen", "钢笔": "fountain pen", "铅笔": "pencil", "毛笔": "brush pen", + "纸": "paper", "笔记本": "notebook", "日记": "diary", "便签": "sticky note", + + // 花卉植物 + "花": "flower", "鲜花": "flower", "花朵": "flower", "花束": "bouquet", + "玫瑰": "rose", "樱花": "cherry blossom", "向日葵": "sunflower", + "郁金香": "tulip", "百合": "lily", "菊花": "chrysanthemum", + "树": "tree", "盆栽": "potted plant", "仙人掌": "cactus", + + // 餐具茶具 + "杯子": "cup", "茶杯": "teacup", "咖啡杯": "coffee cup", + "水杯": "water glass", "酒杯": "wine glass", "马克杯": "mug", + "盘子": "plate", "碗": "bowl", "勺子": "spoon", "叉子": "fork", + "筷子": "chopsticks", "刀": "knife", "茶壶": "teapot", + + // 装饰品 + "镜子": "mirror", "时钟": "clock", "钟": "clock", "闹钟": "alarm clock", + "相框": "photo frame", "画": "painting", "海报": "poster", + "蜡烛": "candle", "台灯": "desk lamp", "花瓶": "vase", + + // 武器道具 + "剑": "sword", "刀": "sword", "匕首": "dagger", "长矛": "spear", + "弓": "bow", "箭": "arrow", "盾": "shield", "铠甲": "armor", + "魔法棒": "magic wand", "法杖": "staff", "水晶球": "crystal ball", + + // 乐器 + "吉他": "guitar", "钢琴": "piano", "小提琴": "violin", + "笛子": "flute", "鼓": "drum", "萨克斯": "saxophone", + + // 电子设备 + "电脑": "computer", "笔记本": "laptop", "平板": "tablet", + "手机": "phone", "相机": "camera", "照相机": "camera", + "电视": "television", "收音机": "radio", "耳机": "headphones", + + // 日用品 + "伞": "umbrella", "雨伞": "umbrella", "遮阳伞": "parasol", + "包": "bag", "书包": "bag", "手提包": "handbag", "背包": "backpack", + "钱包": "wallet", "钥匙": "key", "锁": "lock", + "枕头": "pillow", "抱枕": "pillow", "毯子": "blanket", + "被子": "quilt", "床单": "bedsheet", "毛巾": "towel", + + // 交通工具 + "汽车": "car", "自行车": "bicycle", "摩托车": "motorcycle", + "公交车": "bus", "出租车": "taxi", "卡车": "truck", + "飞机": "airplane", "直升机": "helicopter", "船": "ship", + "游艇": "yacht", "火车": "train", "地铁": "subway", + + // 食物饮品 + "咖啡": "coffee", "茶": "tea", "水": "water", "果汁": "juice", + "蛋糕": "cake", "面包": "bread", "饼干": "cookie", + "苹果": "apple", "香蕉": "banana", "橙子": "orange", + "巧克力": "chocolate", "糖果": "candy", "冰淇淋": "ice cream", + + // 首饰配件 + "项链": "necklace", "手链": "bracelet", "戒指": "ring", + "耳环": "earrings", "胸针": "brooch", "手表": "watch", + "皇冠": "crown", "头饰": "hair accessory", "发卡": "hair clip", + + // 运动用品 + "球": "ball", "篮球": "basketball", "足球": "soccer ball", + "网球": "tennis ball", "乒乓球": "ping pong ball", + "球拍": "racket", "滑板": "skateboard", "轮滑鞋": "roller skates", + + // 玩具 + "玩偶": "doll", "泰迪熊": "teddy bear", "毛绒玩具": "stuffed animal", + "积木": "building blocks", "拼图": "puzzle", "棋": "chess", + "扑克": "playing cards", "骰子": "dice", "风筝": "kite" + }, + + styles: { + // 美感风格 + "可爱": "cute", "美丽": "beautiful", "漂亮": "pretty", + "美": "beautiful", "绝美": "stunning", "惊艳": "gorgeous", + "优雅": "elegant", "高贵": "noble", "华丽": "gorgeous", + "精致": "delicate", "完美": "perfect", "迷人": "charming", + + // 性感风格 + "性感": "sexy", "诱惑": "seductive", "魅惑": "seductive", + "撩人": "alluring", "火辣": "hot", "妖娆": "enchanting", + "风情": "charming", "妩媚": "seductive", "勾人": "alluring", + + // 纯真风格 + "清纯": "innocent", "纯洁": "pure", "天真": "innocent", + "单纯": "naive", "纯真": "pure", "清新": "fresh", + "自然": "natural", "朴素": "simple", "清雅": "elegant", + + // 成熟风格 + "成熟": "mature", "稳重": "mature", "知性": "intellectual", + "干练": "capable", "职业": "professional", "严谨": "rigorous", + "端庄": "dignified", "庄重": "solemn", "典雅": "elegant", + + // 活力风格 + "活泼": "lively", "开朗": "cheerful", "阳光": "bright", + "青春": "youthful", "朝气": "energetic", "活力": "vibrant", + "热情": "passionate", "积极": "positive", "乐观": "optimistic", + + // 冷酷风格 + "神秘": "mysterious", "冷酷": "cool", "高冷": "cold", + "冰冷": "icy", "冷漠": "indifferent", "疏离": "distant", + "孤独": "lonely", "忧郁": "melancholy", "深沉": "deep", + + // 温暖风格 + "温暖": "warm", "舒适": "comfortable", "宁静": "peaceful", + "温和": "gentle", "慈祥": "kind", "亲切": "friendly", + "贴心": "caring", "体贴": "considerate", "善良": "kind", + + // 浪漫风格 + "浪漫": "romantic", "梦幻": "dreamy", "唯美": "aesthetic", + "诗意": "poetic", "文艺": "artistic", "小清新": "fresh", + "治愈": "healing", "暖心": "heartwarming", "甜美": "sweet", + + // 奇幻风格 + "奇幻": "fantasy", "魔幻": "magical", "神秘": "mysterious", + "超现实": "surreal", "梦境": "dreamlike", "虚幻": "illusory", + + // 时代风格 + "古典": "classic", "复古": "vintage", "古风": "ancient style", + "现代": "modern", "时尚": "fashionable", "前卫": "avant-garde", + "未来": "futuristic", "科幻": "sci-fi", "赛博朋克": "cyberpunk", + + // 男性魅力 + "帅气": "handsome", "英俊": "handsome", "潇洒": "dashing", + "俊美": "handsome", "阳刚": "masculine", "威严": "dignified", + "强大": "powerful", "威猛": "mighty", "勇敢": "brave", + "绅士": "gentleman", "风度": "graceful", "魅力": "charismatic", + "霸气": "domineering", "王者": "kingly", "领袖": "leadership" + }, + + activities: { + // 学习活动 + "学习": "studying", "上课": "attending class", "考试": "exam", + "复习": "reviewing", "预习": "previewing", "做题": "solving problems", + "做作业": "homework", "写作业": "homework", "背书": "memorizing", + "研究": "research", "实验": "experiment", "讨论": "discussion", + + // 生活活动 + "做饭": "cooking", "吃饭": "eating", "用餐": "dining", + "喝茶": "drinking tea", "喝咖啡": "drinking coffee", "品茶": "tea tasting", + "洗澡": "bathing", "沐浴": "bathing", "泡澡": "bathing", + "洗漱": "washing up", "刷牙": "brushing teeth", "洗脸": "washing face", + "睡觉": "sleeping", "午睡": "napping", "休息": "resting", + "起床": "getting up", "醒来": "waking up", + + // 购物娱乐 + "购物": "shopping", "逛街": "shopping", "买东西": "shopping", + "逛商场": "mall shopping", "网购": "online shopping", + "看电影": "watching movie", "看电视": "watching TV", "追剧": "binge watching", + "听音乐": "listening to music", "唱歌": "singing", "唱K": "karaoke", + "游戏": "gaming", "玩耍": "playing", "娱乐": "entertainment", + "聊天": "chatting", "谈话": "talking", "交流": "communicating", + + // 运动健身 + "运动": "sports", "健身": "fitness", "锻炼": "exercise", + "跑步": "running", "慢跑": "jogging", "散步": "walking", + "游泳": "swimming", "潜水": "diving", "跳水": "diving", + "登山": "mountain climbing", "徒步": "hiking", "骑行": "cycling", + "瑜伽": "yoga", "舞蹈": "dancing", "跳舞": "dancing", + "太极": "tai chi", "武术": "martial arts", "拳击": "boxing", + + // 工作活动 + "工作": "working", "加班": "overtime", "会议": "meeting", + "开会": "attending meeting", "谈判": "negotiation", "签约": "signing contract", + "出差": "business trip", "培训": "training", "实习": "internship", + + // 社交活动 + "聚会": "party", "庆祝": "celebration", "生日": "birthday", + "聚餐": "group dining", "野餐": "picnic", "烧烤": "barbecue", + "约会": "dating", "恋爱": "romance", "表白": "confession", + "求婚": "proposal", "结婚": "wedding", "婚礼": "wedding ceremony", + "蜜月": "honeymoon", "旅行": "travel", "度假": "vacation", + "观光": "sightseeing", "旅游": "tourism", "探险": "adventure", + + // 文艺活动 + "看书": "reading", "阅读": "reading", "写作": "writing", + "画画": "drawing", "绘画": "painting", "摄影": "photography", + "书法": "calligraphy", "雕刻": "carving", "手工": "handicraft", + "编织": "knitting", "刺绣": "embroidery", "陶艺": "pottery", + + // 情感互动 + "调戏": "teasing", "戏弄": "teasing", "挑逗": "flirting", + "撩": "flirting", "撩拨": "flirting", "勾引": "seduction", + "诱惑": "seduction", "魅惑": "seduction", "撒娇": "acting cute", + "卖萌": "acting cute", "害羞": "shy", "脸红": "blushing", + "接吻": "kissing", "亲吻": "kissing", "亲": "kissing", + "拥抱": "hugging", "抱": "hugging", "搂": "embracing", + "牵手": "holding hands", "握手": "handshake", + "抚摸": "caressing", "爱抚": "caressing", "按摩": "massage", + "安慰": "comforting", "关心": "caring", "照顾": "taking care", + + // 窥视相关 + "偷看": "peeking", "窥视": "voyeur", "偷窥": "voyeur", + "暗中观察": "secretly observing", "跟踪": "following", + "展示": "showing", "炫耀": "showing off", "露出": "exposing", + "表现": "performing", "演示": "demonstrating", + + // 梦境活动 + "梦": "dreaming", "做梦": "dreaming", "梦见": "dreaming", + "梦游": "sleepwalking", "噩梦": "nightmare", "美梦": "sweet dream", + + // 思考活动 + "思考": "thinking", "考虑": "considering", "琢磨": "pondering", + "沉思": "contemplating", "反思": "reflecting", "冥想": "meditation", + "发呆": "daydreaming", "走神": "spacing out", "幻想": "fantasizing", + + // 创作活动 + "创作": "creating", "发明": "inventing", "设计": "designing", + "制作": "making", "建造": "building", "构建": "constructing", + "修理": "repairing", "维修": "fixing", "改造": "renovating" + }, + + body_parts: { + // 头部 + "头": "head", "头部": "head", "脑袋": "head", + "脸": "face", "面部": "face", "容颜": "face", + "额头": "forehead", "脸颊": "cheeks", "下巴": "chin", + "眼睛": "eyes", "眼": "eyes", "眼神": "gaze", "目光": "gaze", + "眉毛": "eyebrows", "睫毛": "eyelashes", "眼皮": "eyelids", + "鼻子": "nose", "鼻": "nose", "鼻梁": "nose bridge", + "嘴": "mouth", "嘴唇": "lips", "舌头": "tongue", + "牙齿": "teeth", "虎牙": "fangs", "门牙": "front teeth", + "耳朵": "ears", "耳": "ears", "耳垂": "earlobes", + "头发": "hair", "发型": "hairstyle", "刘海": "bangs", + + // 颈部胸部 + "脖子": "neck", "颈": "neck", "咽喉": "throat", + "肩膀": "shoulders", "肩": "shoulders", "锁骨": "collarbone", + "胸": "breasts", "胸部": "breasts", "乳房": "breasts", + "胸膛": "chest", "胸口": "chest", "心脏": "heart", + + // 手臂手部 + "手臂": "arms", "臂": "arms", "上臂": "upper arms", + "前臂": "forearms", "肘": "elbows", "肘部": "elbows", + "手": "hands", "手掌": "palms", "手背": "back of hands", + "手指": "fingers", "拇指": "thumbs", "食指": "index fingers", + "中指": "middle fingers", "无名指": "ring fingers", "小指": "pinky fingers", + "指甲": "nails", "手腕": "wrists", "腕": "wrists", + + // 躯干 + "身体": "body", "身材": "figure", "体型": "body type", + "背": "back", "后背": "back", "脊背": "spine", + "腰": "waist", "腰部": "waist", "细腰": "slim waist", + "肚子": "belly", "腹部": "abdomen", "腹": "abdomen", + "肚脐": "navel", "小腹": "lower abdomen", + "臀部": "hips", "屁股": "butt", "臀": "buttocks", + + // 腿部足部 + "腿": "legs", "大腿": "thighs", "小腿": "calves", + "膝盖": "knees", "膝": "knees", "脚踝": "ankles", + "脚": "feet", "足": "feet", "脚掌": "soles", + "脚趾": "toes", "脚指": "toes", "脚跟": "heels", + + // 皮肤相关 + "皮肤": "skin", "肌肤": "skin", "体肤": "skin", + "毛孔": "pores", "汗": "sweat", "体温": "body temperature", + + // 内衣相关 + "胸罩": "bra", "文胸": "bra", "内衣": "underwear", + "内裤": "panties", "底裤": "underwear", "三角裤": "briefs", + "胖次": "panties", "安全裤": "safety shorts" + }, + + nsfw_actions: { + // 基础行为 + "做爱": "sex", "性爱": "sex", "交配": "mating", "性交": "intercourse", + "爱爱": "making love", "啪啪": "sex", "嘿咻": "sex", + + // 插入动作 + "插入": "penetration", "进入": "penetration", "插": "insertion", + "深入": "deep penetration", "浅入": "shallow penetration", + "刺入": "thrusting in", "顶入": "pushing in", + + // 律动动作 + "抽插": "thrusting", "律动": "thrusting", "顶": "thrusting", + "冲撞": "pounding", "撞击": "hitting", "摩擦": "rubbing", + "研磨": "grinding", "扭动": "twisting", "起伏": "undulating", + + // 高潮相关 + "高潮": "orgasm", "达到高潮": "climax", "巅峰": "peak", + "射精": "ejaculation", "释放": "release", "爆发": "explosion", + "喷": "squirting", "涌出": "gushing", "流出": "flowing", + + // 口部动作 + "口交": "oral sex", "含": "sucking", "舔": "licking", + "吸": "sucking", "吮": "sucking", "咬": "biting", + "亲": "kissing", "深吻": "deep kiss", "法式接吻": "french kiss", + + // 体位相关 + "肛交": "anal sex", "后入": "doggy style", "骑乘": "cowgirl", + "传教士": "missionary", "侧位": "side position", "反向": "reverse", + "站立": "standing position", "坐位": "sitting position", + + // 自慰相关 + "手淫": "masturbation", "自慰": "masturbation", "撸": "stroking", + "套弄": "stroking", "摩擦": "rubbing", "刺激": "stimulation", + + // 抚摸动作 + "指交": "fingering", "抚弄": "fondling", "揉": "massaging", + "搓": "rubbing", "捏": "pinching", "压": "pressing", + "按": "pressing", "推": "pushing", "拉": "pulling", + + // 体液相关 + "爱液": "love juice", "精液": "semen", "体液": "bodily fluids", + "分泌": "secretion", "润滑": "lubrication", "湿润": "moisture", + + // 状态描述 + "湿润": "wet", "润滑": "lubricated", "干燥": "dry", + "紧": "tight", "松": "loose", "深": "deep", "浅": "shallow", + "热": "hot", "温暖": "warm", "冰凉": "cold", + "快": "fast", "慢": "slow", "用力": "hard", "轻": "gentle", + "粗暴": "rough", "温柔": "gentle", "激烈": "intense", "缓慢": "slow" + }, + + nsfw_body_parts: { + // 男性器官 + "阴茎": "penis", "鸡巴": "cock", "肉棒": "dick", "老二": "dick", + "鸡鸡": "penis", "小弟弟": "penis", "那话儿": "penis", + "龟头": "glans", "包皮": "foreskin", "马眼": "urethral opening", + "睾丸": "testicles", "蛋蛋": "balls", "精囊": "seminal vesicles", + + // 女性器官 + "阴道": "vagina", "小穴": "pussy", "阴唇": "labia", "花瓣": "labia", + "阴蒂": "clitoris", "豆豆": "clitoris", "小核": "clitoris", + "阴户": "vulva", "私处": "private parts", "花径": "vagina", + "子宫": "womb", "宫口": "cervix", "G点": "g-spot", "敏感点": "sensitive spot", + + // 共同部位 + "肛门": "anus", "菊花": "asshole", "后庭": "backdoor", "屁眼": "butthole", + "会阴": "perineum", "下体": "genitals", "性器": "sex organ", + "私密处": "intimate parts", "敏感带": "erogenous zone", + + // 胸部 + "乳头": "nipples", "奶头": "nipples", "乳晕": "areola", + "奶子": "tits", "胸脯": "breasts", "酥胸": "soft breasts", + "双峰": "twin peaks", "玉兔": "breasts", "雪峰": "white breasts", + + // 其他敏感部位 + "大腿根": "inner thighs", "腿间": "between legs", "股间": "crotch", + "后穴": "back hole", "前穴": "front hole", "蜜穴": "honey pot", + "花心": "deep inside", "花芯": "core", "深处": "deep inside", + + // 生理反应 + "勃起": "erection", "坚挺": "stiff", "充血": "engorged", + "湿润": "wet", "分泌": "secreting", "流水": "dripping", + "收缩": "contracting", "痉挛": "spasming", "颤抖": "trembling", + + // 特殊词汇 + "前列腺": "prostate", "尿道": "urethra", "处女膜": "hymen", + "欲火": "lust", "春情": "arousal", "情欲": "passion" + }, + + nsfw_states: { + // 男性状态 + "勃起": "erect", "硬": "hard", "坚挺": "stiff", "挺立": "standing", + "半勃": "semi-erect", "软": "soft", "疲软": "limp", + "胀大": "swollen", "充血": "engorged", "青筋暴起": "veiny", + + // 女性状态 + "湿": "wet", "潮湿": "moist", "流水": "dripping", "湿润": "lubricated", + "干涩": "dry", "紧致": "tight", "松弛": "loose", + "夹紧": "clenching", "收缩": "contracting", "痉挛": "spasming", + + // 共同状态 + "胀": "swollen", "肿": "enlarged", "充血": "engorged", + "敏感": "sensitive", "酥麻": "tingling", "颤抖": "trembling", + "战栗": "shivering", "痉挛": "convulsing", "抽搐": "twitching", + + // 情绪状态 + "兴奋": "aroused", "激动": "excited", "冲动": "horny", + "发情": "in heat", "春心荡漾": "aroused", "欲火焚身": "lustful", + "欲火": "lustful", "渴望": "craving", "饥渴": "thirsty", + "急需": "desperate", "忍耐": "enduring", "煎熬": "suffering", + + // 满足状态 + "满足": "satisfied", "充实": "fulfilled", "空虚": "empty", + "饱满": "full", "撑胀": "stretched", "填满": "filled", + "深入": "deep", "顶到": "hitting", "碰到": "touching", + + // 感觉状态 + "疼": "painful", "痛": "aching", "酸": "sore", + "爽": "pleasurable", "舒服": "comfortable", "快感": "pleasure", + "酥": "tingling", "麻": "numb", "痒": "itchy", + "热": "hot", "烫": "burning", "凉": "cool", + "涨": "swelling", "胀": "bloated", "紧": "tight", + + // 程度状态 + "轻微": "slight", "强烈": "intense", "剧烈": "violent", + "温和": "gentle", "激烈": "fierce", "疯狂": "crazy", + "缓慢": "slow", "急促": "rapid", "持续": "continuous" + }, + + nsfw_sounds: { + // 呻吟声 + "呻吟": "moaning", "叫床": "moaning", "娇喘": "panting", + "喘息": "breathing heavily", "急喘": "panting", "粗喘": "heavy breathing", + + // 基础音节 + "哼": "humming", "嗯": "mmm", "唔": "mmm", + "啊": "ah", "哦": "oh", "噢": "oh", + "嘤": "whimpering", "嘤嘤": "whimpering", "嘤嘤嘤": "whimpering", + + // 高音调 + "尖叫": "screaming", "尖声": "high-pitched", "细声": "thin voice", + "呼喊": "crying out", "大叫": "shouting", "惊叫": "exclaiming", + + // 低音调 + "低吟": "groaning", "闷哼": "muffled moan", "低喃": "mumbling", + "嘟囔": "muttering", "咕哝": "grumbling", "轻哼": "soft humming", + + // 情绪音 + "啜泣": "sobbing", "哽咽": "choking", "抽泣": "sniffling", + "颤音": "trembling voice", "破音": "voice breaking", + + // 生理音 + "喘气": "gasping", "倒抽气": "sharp intake", "屏息": "holding breath", + "换气": "catching breath", "深呼吸": "deep breathing", + + // 其他音效 + "叫声": "vocal", "声音": "sounds", "噪音": "noise", + "音调": "tone", "音量": "volume", "回音": "echo", + "轻声": "whisper", "细语": "soft voice", "耳语": "whispering", + "颤抖": "trembling", "战栗": "shivering", "哆嗦": "quivering" + }, + + nsfw_descriptions: { + // 基础描述 + "色情": "pornographic", "淫荡": "lewd", "下流": "vulgar", + "猥亵": "obscene", "淫秽": "indecent", "不雅": "improper", + "淫乱": "promiscuous", "放荡": "wanton", "骚": "slutty", + "浪": "naughty", "风骚": "seductive", "妖艳": "bewitching", + + // 性格特征 + "骚货": "slut", "淫娃": "sex kitten", "小妖精": "little minx", + "小浪蹄子": "little slut", "狐狸精": "vixen", "妖女": "seductress", + "处女": "virgin", "纯洁": "pure", "清纯": "innocent", + "无辜": "innocent", "天真": "naive", "单纯": "simple", + + // 经验程度 + "经验": "experienced", "老练": "skilled", "熟练": "proficient", + "熟女": "mature woman", "老司机": "experienced", "新手": "beginner", + "生涩": "inexperienced", "青涩": "green", "稚嫩": "tender", + + // 特殊嗜好 + "禁忌": "taboo", "变态": "pervert", "扭曲": "twisted", + "病态": "sick", "不正常": "abnormal", "特殊": "special", + "癖好": "fetish", "嗜好": "preference", "口味": "taste", + + // 权力关系 + "调教": "training", "驯服": "taming", "征服": "conquering", + "支配": "domination", "统治": "ruling", "控制": "control", + "服从": "submission", "屈服": "yielding", "顺从": "obedient", + "奴隶": "slave", "奴": "slave", "宠物": "pet", + "主人": "master", "主": "master", "女王": "queen", + "女主": "mistress", "王": "king", "君主": "sovereign", + + // 强度描述 + "轻柔": "gentle", "温和": "mild", "激烈": "intense", + "粗暴": "rough", "野蛮": "savage", "狂野": "wild", + "疯狂": "crazy", "极端": "extreme", "过分": "excessive" + }, + + intimate_settings: { + // 私密场所 + "床": "bed", "床上": "on bed", "大床": "big bed", + "单人床": "single bed", "双人床": "double bed", "水床": "waterbed", + "床单": "bedsheet", "被子": "blanket", "枕头": "pillow", + "被窝": "under blanket", "毯子": "blanket", "软垫": "soft mat", + + // 卧室环境 + "卧室": "bedroom", "主卧": "master bedroom", "客房": "guest room", + "宿舍": "dormitory", "公寓": "apartment", "套房": "suite", + "酒店房间": "hotel room", "民宿": "bed and breakfast", + + // 浴室场所 + "浴室": "bathroom", "洗手间": "bathroom", "淋浴间": "shower room", + "浴缸": "bathtub", "按摩浴缸": "jacuzzi", "淋浴": "shower", + "蒸汽浴": "steam bath", "桑拿": "sauna", "温泉": "hot spring", + + // 客厅家具 + "沙发": "sofa", "长沙发": "couch", "皮沙发": "leather sofa", + "躺椅": "recliner", "懒人椅": "lazy chair", "摇椅": "rocking chair", + "地毯": "carpet", "地垫": "mat", "地板": "floor", + "茶几": "coffee table", "边桌": "side table", + + // 其他家具 + "桌子": "table", "书桌": "desk", "梳妆台": "dressing table", + "椅子": "chair", "办公椅": "office chair", "吧台椅": "bar stool", + "墙": "wall", "墙角": "corner", "窗台": "windowsill", + "阳台": "balcony", "露台": "terrace", "天台": "rooftop", + + // 交通工具 + "车里": "in car", "后座": "back seat", "驾驶座": "driver seat", + "副驾驶": "passenger seat", "货车": "truck", "面包车": "van", + "火车": "train", "飞机": "airplane", "游艇": "yacht", + + // 户外场所 + "野外": "outdoors", "森林": "forest", "树林": "woods", + "海滩": "beach", "沙滩": "sandy beach", "海边": "seaside", + "草地": "grassland", "花园": "garden", "公园": "park", + "山顶": "mountain top", "山洞": "cave", "帐篷": "tent", + + // 特殊场所 + "办公室": "office", "会议室": "meeting room", "储藏室": "storage room", + "教室": "classroom", "图书馆": "library", "实验室": "laboratory", + "厕所": "toilet", "洗手间": "restroom", "更衣室": "changing room", + "试衣间": "fitting room", "化妆间": "dressing room", + "健身房": "gym", "瑜伽室": "yoga room", "舞蹈室": "dance studio", + + // 住宿场所 + "酒店": "hotel", "旅馆": "motel", "民宿": "guesthouse", + "度假村": "resort", "别墅": "villa", "小屋": "cabin", + "招待所": "hostel", "青旅": "youth hostel" + }, + + fetish_categories: { + // 服装恋物 + "丝袜": "stockings", "黑丝": "black stockings", "白丝": "white stockings", + "连裤袜": "pantyhose", "网袜": "fishnet stockings", "过膝袜": "thigh highs", + "高跟鞋": "high heels", "靴子": "boots", "长靴": "knee boots", + "制服": "uniform", "学生装": "school uniform", "护士装": "nurse outfit", + "女仆装": "maid outfit", "空姐装": "flight attendant uniform", + + // 材质恋物 + "蕾丝": "lace", "真丝": "silk", "缎子": "satin", + "皮革": "leather", "乳胶": "latex", "橡胶": "rubber", + "PVC": "pvc", "金属": "metal", "链条": "chain", + + // 束缚用具 + "束缚": "bondage", "绳子": "rope", "绳索": "rope", + "手铐": "handcuffs", "脚镣": "shackles", "锁链": "chains", + "眼罩": "blindfold", "口球": "gag", "项圈": "collar", + "皮带": "belt", "背带": "harness", "束身衣": "corset", + + // 调教用具 + "鞭子": "whip", "皮鞭": "leather whip", "马鞭": "riding crop", + "板子": "paddle", "藤条": "cane", "羽毛": "feather", + "蜡烛": "candle", "蜡油": "wax", "冰块": "ice", + "夹子": "clamps", "乳夹": "nipple clamps", "刑具": "torture device", + + // 情趣用品 + "玩具": "toy", "按摩棒": "vibrator", "震动棒": "vibrator", + "假阳具": "dildo", "双头龙": "double dildo", "仿真器": "realistic toy", + "跳蛋": "bullet vibrator", "遥控器": "remote control", "震动器": "vibrator", + "肛塞": "butt plug", "前列腺": "prostate massager", "扩张器": "dilator", + "充气娃娃": "sex doll", "飞机杯": "masturbator", "倒模": "pocket pussy", + + // 特殊恋物 + "触手": "tentacle", "怪物": "monster", "野兽": "beast", + "异形": "alien", "机器人": "robot", "人偶": "doll", + "机器": "machine", "机械": "mechanical", "人工": "artificial", + "科技": "technology", "虚拟": "virtual", "全息": "holographic", + + // 材质特殊 + "毛绒": "fur", "羽毛": "feather", "丝绸": "silk", + "天鹅绒": "velvet", "绒毛": "fuzzy", "光滑": "smooth", + "粗糙": "rough", "硬质": "hard", "软质": "soft" + }, + + body_modifications: { + // 纹身类型 + "纹身": "tattoo", "刺青": "tattoo", "花臂": "sleeve tattoo", + "图腾": "tribal tattoo", "文字": "text tattoo", "图案": "pattern tattoo", + "彩绘": "body painting", "临时纹身": "temporary tattoo", + "传统纹身": "traditional tattoo", "日式纹身": "japanese tattoo", + + // 穿孔类型 + "穿孔": "piercing", "打洞": "piercing", "耳洞": "ear piercing", + "鼻环": "nose ring", "唇环": "lip ring", "舌环": "tongue piercing", + "肚脐环": "navel piercing", "乳环": "nipple piercing", + "私处穿孔": "genital piercing", "眉环": "eyebrow piercing", + + // 自然标记 + "疤痕": "scar", "伤疤": "scar", "刀疤": "knife scar", + "胎记": "birthmark", "痣": "mole", "黑痣": "dark mole", + "雀斑": "freckles", "斑点": "spots", "色斑": "pigmentation", + "美人痣": "beauty mark", "泪痣": "tear mole", + + // 肌肉特征 + "肌肉": "muscle", "腹肌": "abs", "六块腹肌": "six pack", + "八块腹肌": "eight pack", "人鱼线": "v-line", "马甲线": "ab line", + "肱二头肌": "biceps", "胸肌": "pectoral muscles", "背肌": "back muscles", + "臀肌": "glutes", "大腿肌": "thigh muscles", "小腿肌": "calf muscles", + + // 骨骼特征 + "锁骨": "collarbone", "肩胛骨": "shoulder blade", "脊椎": "spine", + "肋骨": "ribs", "髋骨": "hip bone", "颧骨": "cheekbone", + "下颌": "jawline", "尖下巴": "pointed chin", "方下巴": "square jaw", + + // 身体凹陷 + "腰窝": "dimples", "酒窝": "dimples", "梨涡": "dimples", + "锁骨窝": "collarbone hollow", "太阳穴": "temples", + "颈窝": "neck hollow", "脚踝窝": "ankle hollow", + + // 特殊特征 + "虎牙": "fangs", "小虎牙": "small fangs", "门牙": "front teeth", + "双眼皮": "double eyelids", "单眼皮": "single eyelids", + "卧蚕": "aegyo sal", "眼袋": "eye bags", "鱼尾纹": "crow's feet", + "法令纹": "nasolabial folds", "颈纹": "neck lines" + }, + + clothing_states: { + // 脱衣状态 + "裸体": "nude", "全裸": "completely nude", "一丝不挂": "stark naked", + "半裸": "topless", "上身裸体": "topless", "下身裸体": "bottomless", + "微露": "slightly exposed", "若隐若现": "faintly visible", + + // 穿着状态 + "穿戴整齐": "fully dressed", "衣冠楚楚": "well-dressed", + "衣衫不整": "disheveled", "衣不蔽体": "barely clothed", + "衣衫褴褛": "ragged clothes", "破烂": "tattered", + + // 材质状态 + "透明": "transparent", "半透明": "see-through", "透视": "see-through", + "薄": "thin", "厚": "thick", "轻薄": "light", + "厚重": "heavy", "柔软": "soft", "粗糙": "rough", + "光滑": "smooth", "有光泽": "glossy", "无光": "matte", + + // 合身程度 + "紧身": "tight", "贴身": "form-fitting", "修身": "slim-fit", + "宽松": "loose", "肥大": "oversized", "合身": "well-fitted", + "过大": "too big", "过小": "too small", "刚好": "just right", + + // 长度状态 + "短": "short", "超短": "very short", "迷你": "mini", + "长": "long", "超长": "very long", "及地": "floor-length", + "中等": "medium", "标准": "standard", "正常": "normal", + + // 暴露程度 + "露": "exposed", "露出": "showing", "展示": "displaying", + "暴露": "revealing", "性感": "sexy", "保守": "conservative", + "大胆": "bold", "开放": "open", "含蓄": "modest", + "若隐若现": "peek-a-boo", "欲盖弥彰": "teasingly covered", + + // 穿脱动作 + "脱": "undressing", "脱下": "taking off", "褪去": "removing", + "穿": "dressing", "穿上": "putting on", "套": "slipping on", + "换": "changing", "更衣": "changing clothes", "试穿": "trying on", + "扯": "pulling", "撕": "tearing", "剪": "cutting", + + // 衣物状态 + "破": "torn", "破洞": "holes", "开口": "opening", + "裂缝": "crack", "撕裂": "ripped", "磨损": "worn", + "湿": "wet", "潮湿": "damp", "浸湿": "soaked", + "干": "dry", "干燥": "dried", "干净": "clean", + "脏": "dirty", "污": "stained", "染色": "colored", + "乱": "messy", "凌乱": "disheveled", "整齐": "neat", + "皱": "wrinkled", "平整": "smooth", "熨烫": "ironed" + }, + + romance_keywords: { + // 关系称谓 + "恋人": "lovers", "情侣": "couple", "爱侣": "lovers", + "男友": "boyfriend", "女友": "girlfriend", "伴侣": "partner", + "爱人": "lover", "心上人": "sweetheart", "意中人": "beloved", + "真爱": "true love", "挚爱": "beloved", "最爱": "favorite", + + // 恋爱类型 + "初恋": "first love", "暗恋": "crush", "单恋": "unrequited love", + "热恋": "passionate love", "苦恋": "painful love", "禁恋": "forbidden love", + "师生恋": "teacher-student romance", "办公室恋情": "office romance", + "远距离恋爱": "long distance relationship", "网恋": "online romance", + + // 情感状态 + "心动": "heartbeat", "怦然心动": "heart racing", "一见钟情": "love at first sight", + "脸红心跳": "blushing", "心跳加速": "racing heart", "心如鹿撞": "heart pounding", + "心花怒放": "heart blooming", "心潮澎湃": "surging emotions", + "情不自禁": "can't help oneself", "难以自拔": "unable to extricate", + + // 甜蜜情感 + "甜蜜": "sweet", "温馨": "warm", "浪漫": "romantic", + "幸福": "happy", "快乐": "joyful", "满足": "satisfied", + "陶醉": "intoxicated", "沉醉": "drunk with love", "痴迷": "infatuated", + "甜腻": "sickeningly sweet", "蜜糖": "honey", "糖分": "sweetness", + + // 思念情感 + "想念": "missing", "思念": "longing", "牵挂": "caring", + "惦记": "thinking of", "念念不忘": "unforgettable", "朝思暮想": "thinking day and night", + "魂牵梦绕": "haunting dreams", "日思夜想": "thinking constantly", + "相思": "lovesickness", "离愁": "separation sorrow", + + // 嫉妒情感 + "嫉妒": "jealous", "吃醋": "jealous", "争风吃醋": "jealous rivalry", + "醋意": "jealousy", "占有欲": "possessiveness", "独占": "monopolize", + "不安": "unease", "担心": "worry", "猜疑": "suspicion", + + // 分合状态 + "表白": "confession", "告白": "confession", "求爱": "courtship", + "追求": "pursuit", "示爱": "showing love", "求婚": "proposal", + "订婚": "engagement", "结婚": "marriage", "蜜月": "honeymoon", + "分手": "breakup", "分离": "separation", "离别": "parting", + "复合": "reunion", "和好": "reconcile", "重归于好": "getting back together", + + // 亲密行为 + "约会": "dating", "约会": "date", "幽会": "rendezvous", + "散步": "walk together", "看电影": "watch movie", "吃饭": "dinner date", + "牵手": "holding hands", "拥抱": "hugging", "接吻": "kissing", + "依偎": "cuddling", "偎依": "snuggling", "相拥": "embracing", + + // 情话表达 + "情话": "love words", "甜言蜜语": "sweet words", "告白": "confession", + "承诺": "promise", "誓言": "vow", "山盟海誓": "eternal vow", + "海枯石烂": "until seas dry", "天长地久": "everlasting", + "白头偕老": "grow old together", "永结同心": "united forever", + + // 情感深度 + "深爱": "deep love", "挚爱": "cherished love", "痴情": "devoted love", + "专情": "faithful love", "深情": "deep affection", "真情": "true feelings", + "纯情": "pure love", "真心": "sincere heart", "诚意": "sincerity", + "用心": "heartfelt", "全心全意": "wholeheartedly", "一心一意": "single-minded" + }, + + emotional_states: { + // 欲望相关 + "欲望": "desire", "渴望": "longing", "冲动": "impulse", + "饥渴": "thirsty", "急需": "desperate", "迫切": "urgent", + "强烈": "intense", "炽热": "burning", "火热": "passionate", + "狂野": "wild", "疯狂": "crazy", "失控": "out of control", + + // 兴奋状态 + "兴奋": "excited", "激动": "aroused", "亢奋": "euphoric", + "刺激": "stimulation", "快感": "pleasure", "爽": "pleasurable", + "舒服": "comfortable", "畅快": "exhilarating", "痛快": "satisfying", + "过瘾": "addictive", "上瘾": "addicted", "沉迷": "obsessed", + + // 满足状态 + "满足": "satisfied", "充实": "fulfilled", "完整": "complete", + "愉悦": "pleasure", "快乐": "joy", "幸福": "happiness", + "陶醉": "intoxicated", "沉醉": "drunk", "迷醉": "enchanted", + "销魂": "ecstatic", "飘飘然": "floating", "如痴如醉": "mesmerized", + + // 紧张焦虑 + "紧张": "nervous", "不安": "anxious", "忐忑": "restless", + "慌张": "flustered", "手足无措": "at a loss", "局促": "awkward", + "窘迫": "embarrassed", "尴尬": "awkward", "难堪": "mortified", + "焦虑": "anxious", "担忧": "worried", "忧虑": "concerned", + + // 期待好奇 + "期待": "anticipation", "盼望": "looking forward", "向往": "yearning", + "好奇": "curious", "感兴趣": "interested", "想知道": "wondering", + "探索": "exploration", "发现": "discovery", "新奇": "novelty", + "惊喜": "surprise", "意外": "unexpected", "震撼": "shocking", + + // 羞耻害羞 + "羞耻": "shame", "羞愧": "ashamed", "惭愧": "guilty", + "不好意思": "embarrassed", "难为情": "shy", "脸红": "blushing", + "害羞": "shy", "腼腆": "bashful", "扭捏": "coy", + "矜持": "reserved", "含蓄": "modest", "内敛": "introverted", + + // 大胆主动 + "大胆": "bold", "勇敢": "brave", "无畏": "fearless", + "主动": "proactive", "积极": "active", "进取": "aggressive", + "直接": "direct", "坦率": "frank", "开放": "open", + "放得开": "uninhibited", "豪放": "unrestrained", "奔放": "wild", + + // 被动顺从 + "被动": "passive", "消极": "negative", "退缩": "withdrawn", + "顺从": "submissive", "听话": "obedient", "乖巧": "well-behaved", + "温顺": "docile", "柔顺": "gentle", "配合": "cooperative", + "依赖": "dependent", "依恋": "attached", "粘人": "clingy", + + // 反抗挣扎 + "反抗": "resistant", "抗拒": "resisting", "反对": "opposing", + "挣扎": "struggling", "反抗": "rebelling", "违抗": "defying", + "拒绝": "refusing", "推辞": "declining", "回避": "avoiding", + "逃避": "escaping", "躲避": "hiding", "闪躲": "dodging", + + // 情感波动 + "矛盾": "conflicted", "纠结": "tangled", "复杂": "complicated", + "混乱": "confused", "迷茫": "lost", "困惑": "puzzled", + "犹豫": "hesitant", "踌躇": "hesitating", "不决": "undecided", + "摇摆": "wavering", "动摇": "shaken", "不定": "unstable" + } +}; + +let isProcessing = false; +let currentProgressButton = null; +let processedMessages = new Map(); +let currentImageUrl = null; +let currentSettings = null; +let lastScreenSize = null; + +function getCurrentScreenSize() { + return window.innerWidth <= 1000 ? 'small' : 'large'; +} + +function handleWindowResize() { + if (!isActive()) return; + + const currentScreenSize = getCurrentScreenSize(); + + if (lastScreenSize && lastScreenSize !== currentScreenSize && currentImageUrl && currentSettings) { + $('#wallhaven-app-background, #wallhaven-chat-background').remove(); + $('#wallhaven-app-overlay, #wallhaven-chat-overlay').remove(); + + applyBackgroundToApp(currentImageUrl, currentSettings); + } + + lastScreenSize = currentScreenSize; +} + +function clearBackgroundState() { + document.querySelectorAll('[id^="wallhaven-"]').forEach(el => el.remove()); + currentImageUrl = null; + currentSettings = null; + lastScreenSize = null; +} + +function getWallhavenSettings() { + if (!extension_settings[EXT_ID].wallhavenBackground) { + extension_settings[EXT_ID].wallhavenBackground = structuredClone(defaultSettings); + } + const settings = extension_settings[EXT_ID].wallhavenBackground; + for (const key in defaultSettings) { + if (settings[key] === undefined) { + settings[key] = defaultSettings[key]; + } + } + return settings; +} + +function isActive() { + if (!window.isXiaobaixEnabled) return false; + const settings = getWallhavenSettings(); + return settings.enabled; +} + +function isLandscapeOrientation() { + return window.innerWidth > window.innerHeight; +} + +function getRatiosForOrientation() { + if (isLandscapeOrientation()) { + return "16x9,16x10,21x9"; + } else { + return "9x16,10x16,1x1,9x18"; + } +} + +function showProgressInMessageHeader(messageElement, text) { + const flexContainer = messageElement.querySelector('.flex-container.flex1.alignitemscenter'); + if (!flexContainer) return null; + + removeProgressFromMessageHeader(); + + const progressButton = document.createElement('div'); + progressButton.className = 'mes_btn wallhaven_progress_indicator'; + progressButton.style.cssText = ` + color: #007acc !important; + cursor: default !important; + font-size: 11px !important; + padding: 2px 6px !important; + opacity: 0.9; + `; + progressButton.innerHTML = `${text}`; + progressButton.title = '正在为消息生成配图...'; + + flexContainer.appendChild(progressButton); + currentProgressButton = progressButton; + + return progressButton; +} + +function updateProgressText(text) { + if (currentProgressButton) { + currentProgressButton.innerHTML = `${text}`; + } +} + +function removeProgressFromMessageHeader() { + if (currentProgressButton) { + currentProgressButton.remove(); + currentProgressButton = null; + } + document.querySelectorAll('.wallhaven_progress_indicator').forEach(el => el.remove()); +} + +function renderCustomTagsList() { + const settings = getWallhavenSettings(); + const container = document.getElementById('wallhaven_custom_tags_list'); + if (!container) return; + + container.innerHTML = ''; + + if (!settings.customTags || settings.customTags.length === 0) { + container.innerHTML = '
暂无自定义标签
'; + return; + } + + settings.customTags.forEach(tag => { + const tagElement = document.createElement('div'); + tagElement.className = 'custom-tag-item'; + tagElement.innerHTML = ` + ${tag} + × + `; + container.appendChild(tagElement); + }); + + container.querySelectorAll('.custom-tag-remove').forEach(btn => { + btn.addEventListener('click', function() { + removeCustomTag(this.dataset.tag); + }); + }); +} + +function addCustomTag(tag) { + if (!tag || !tag.trim()) return; + + tag = tag.trim().toLowerCase(); + const settings = getWallhavenSettings(); + + if (!settings.customTags) { + settings.customTags = []; + } + + if (settings.customTags.includes(tag)) { + return false; + } + + settings.customTags.push(tag); + saveSettingsDebounced(); + renderCustomTagsList(); + return true; +} + +function removeCustomTag(tag) { + const settings = getWallhavenSettings(); + if (!settings.customTags) return; + + const index = settings.customTags.indexOf(tag); + if (index > -1) { + settings.customTags.splice(index, 1); + saveSettingsDebounced(); + renderCustomTagsList(); + } +} + +function extractTagsFromText(text, isBgMode = false) { + const settings = getWallhavenSettings(); + + const customTagObjs = (settings.customTags || []).map(tag => ({ + tag: tag, + category: 'custom', + weight: tagWeights.custom, + position: text.lastIndexOf(tag) + })); + + if (isBgMode) { + const bgCategories = ['locations', 'weather_time', 'objects']; + const tagsByCategory = {}; + + bgCategories.forEach(category => { + tagsByCategory[category] = []; + if (wallhavenTags[category]) { + Object.entries(wallhavenTags[category]).forEach(([chinese, english]) => { + const lastPos = text.lastIndexOf(chinese); + if (lastPos !== -1) { + tagsByCategory[category].push({ + tag: english, + category: category, + weight: tagWeights[category] || 1, + position: lastPos, + chinese: chinese + }); + } + }); + } + }); + + const selectedTags = [...customTagObjs]; + + Object.entries(tagsByCategory).forEach(([category, tags]) => { + if (tags.length === 0) return; + + tags.sort((a, b) => b.position - a.position); + + const selectedFromCategory = tags.slice(0, 1); + selectedFromCategory.forEach(tagObj => { + selectedTags.push({ + tag: tagObj.tag, + category: tagObj.category, + weight: tagObj.weight + }); + }); + }); + + if (selectedTags.length === customTagObjs.length) { + selectedTags.push({ tag: 'landscape', category: 'background_fallback', weight: 1 }); + } + + return { tags: selectedTags }; + } else { + + const tagsByCategory = {}; + + Object.keys(wallhavenTags).forEach(category => { + tagsByCategory[category] = []; + Object.entries(wallhavenTags[category]).forEach(([chinese, english]) => { + const lastPos = text.lastIndexOf(chinese); + if (lastPos !== -1) { + tagsByCategory[category].push({ + tag: english, + category: category, + weight: tagWeights[category] || 1, + position: lastPos, + chinese: chinese + }); + } + }); + }); + + const selectedTags = [...customTagObjs]; + + Object.entries(tagsByCategory).forEach(([category, tags]) => { + if (tags.length === 0) return; + + tags.sort((a, b) => b.position - a.position); + + let maxCount = 1; + if (['characters', 'clothing', 'body_features'].includes(category)) { + maxCount = 2; + } + + const selectedFromCategory = tags.slice(0, maxCount); + selectedFromCategory.forEach(tagObj => { + selectedTags.push({ + tag: tagObj.tag, + category: tagObj.category, + weight: tagObj.weight + }); + }); + }); + + return { tags: selectedTags }; + } +} + +async function fetchWithCFWorker(targetUrl) { + const cfWorkerUrl = 'https://wallhaven.velure.top/?url='; + const finalUrl = cfWorkerUrl + encodeURIComponent(targetUrl); + + const response = await fetch(finalUrl); + if (!response.ok) { + throw new Error(`CF Worker请求失败: HTTP ${response.status} - ${response.statusText}`); + } + return response; +} + +async function searchSingleTag(tagObj, category, purity, isBgMode) { + let searchTag = tagObj.tag; + if (isBgMode) { + searchTag = `${tagObj.tag} -girl -male -people -anime`; + } + const ratios = getRatiosForOrientation(); + const wallhavenUrl = `https://wallhaven.cc/api/v1/search?q=${encodeURIComponent(searchTag)}&categories=${category}&purity=${purity}&ratios=${ratios}&sorting=favorites&page=1&`; + + try { + const response = await fetchWithCFWorker(wallhavenUrl); + const data = await response.json(); + return { + tagObj: tagObj, + success: true, + total: data.meta.total, + images: data.data || [] + }; + } catch (error) { + return { + tagObj: tagObj, + success: false, + error: error.message, + total: 0, + images: [] + }; + } +} + +async function intelligentTagMatching(tagObjs, settings) { + if (!tagObjs || tagObjs.length === 0) { + throw new Error('没有可用的标签'); + } + + const allImages = new Map(); + + for (let i = 0; i < tagObjs.length; i++) { + if (!isActive()) { + throw new Error('功能已禁用'); + } + + const tagObj = tagObjs[i]; + const isCustom = tagObj.category === 'custom' ? '[自定义]' : ''; + updateProgressText(`搜索 ${i + 1}/${tagObjs.length}: ${isCustom}${tagObj.tag} (权重${tagObj.weight})`); + const result = await searchSingleTag(tagObj, settings.category, settings.purity, settings.bgMode); + if (result.success) { + result.images.forEach(img => { + if (!allImages.has(img.id)) { + allImages.set(img.id, { + ...img, + matchedTags: [tagObj], + weightedScore: tagObj.weight + }); + } else { + const existingImg = allImages.get(img.id); + existingImg.matchedTags.push(tagObj); + existingImg.weightedScore += tagObj.weight; + } + }); + } + if (i < tagObjs.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + const allImagesArray = Array.from(allImages.values()); + if (allImagesArray.length === 0) { + throw new Error('所有标签都没有找到匹配的图片'); + } + + allImagesArray.sort((a, b) => { + if (b.weightedScore !== a.weightedScore) { + return b.weightedScore - a.weightedScore; + } + return b.favorites - a.favorites; + }); + + const maxWeightedScore = allImagesArray[0].weightedScore; + const bestMatches = allImagesArray.filter(img => img.weightedScore === maxWeightedScore); + const randomIndex = Math.floor(Math.random() * bestMatches.length); + + return bestMatches[randomIndex]; +} + +function applyMessageStyling() { + const mesElements = document.querySelectorAll('#chat .mes:not([data-wallhaven-styled])'); + mesElements.forEach(mes => { + mes.style.cssText += ` + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + background-color: transparent !important; + box-shadow: none !important; + position: relative !important; + z-index: 1002 !important; + `; + mes.setAttribute('data-wallhaven-styled', 'true'); + }); + + const mesTextElements = document.querySelectorAll('#chat .mes_text:not([data-wallhaven-text-styled])'); + mesTextElements.forEach(mesText => { + mesText.style.cssText += ` + text-shadow: rgba(0, 0, 0, 0.8) 1px 1px 2px !important; + color: inherit !important; + position: relative !important; + z-index: 1003 !important; + `; + mesText.setAttribute('data-wallhaven-text-styled', 'true'); + }); + + const messageElements = document.querySelectorAll('#chat .mes, #chat .mes_text, #chat .name, #chat .mes_img, #chat .mes_avatar, #chat .mes_btn'); + messageElements.forEach(element => { + if (element && !element.hasAttribute('data-wallhaven-z-styled')) { + element.style.cssText += ` + position: relative !important; + z-index: 1002 !important; + `; + element.setAttribute('data-wallhaven-z-styled', 'true'); + } + }); +} + +function applyBackgroundToApp(imageUrl, settings) { + currentImageUrl = imageUrl; + currentSettings = { ...settings }; + lastScreenSize = getCurrentScreenSize(); + + const isSmallScreen = window.innerWidth <= 1000; + + if (isSmallScreen) { + const chatElement = document.getElementById('chat'); + if (!chatElement) return; + + const bgId = 'wallhaven-mobile-background'; + const overlayId = 'wallhaven-mobile-overlay'; + + document.querySelectorAll('[id^="wallhaven-"]').forEach(el => el.remove()); + + let topOffset = 0; + const rightNavHolder = document.getElementById('rightNavHolder'); + if (rightNavHolder) { + const rect = rightNavHolder.getBoundingClientRect(); + topOffset = rect.bottom; + } else { + topOffset = 50; + } + + let backgroundContainer = document.getElementById(bgId); + let overlay = document.getElementById(overlayId); + + if (!backgroundContainer) { + backgroundContainer = document.createElement('div'); + backgroundContainer.id = bgId; + backgroundContainer.style.cssText = ` + position: fixed !important; + top: ${topOffset}px !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: calc(100vh - ${topOffset}px) !important; + background-size: 100% auto !important; + background-position: top center !important; + background-repeat: no-repeat !important; + z-index: -1 !important; + pointer-events: none !important; + overflow: hidden !important; + `; + document.body.appendChild(backgroundContainer); + } + + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = overlayId; + overlay.style.cssText = ` + position: fixed !important; + top: ${topOffset}px !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: calc(100vh - ${topOffset}px) !important; + background-color: rgba(0, 0, 0, ${settings.opacity}) !important; + z-index: 0 !important; + pointer-events: none !important; + overflow: hidden !important; + `; + document.body.appendChild(overlay); + } + + backgroundContainer.style.backgroundImage = `url("${imageUrl}")`; + overlay.style.backgroundColor = `rgba(0, 0, 0, ${settings.opacity})`; + + backgroundContainer.style.top = `${topOffset}px`; + backgroundContainer.style.height = `calc(100vh - ${topOffset}px)`; + overlay.style.top = `${topOffset}px`; + overlay.style.height = `calc(100vh - ${topOffset}px)`; + + if (chatElement) { + chatElement.style.cssText += ` + background-color: transparent !important; + background-image: none !important; + background: transparent !important; + position: relative !important; + z-index: 1 !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + `; + } + + applyMessageStyling(); + + } else { + const targetContainer = document.getElementById('expression-wrapper'); + if (!targetContainer) return; + + const bgId = 'wallhaven-app-background'; + const overlayId = 'wallhaven-app-overlay'; + + let backgroundContainer = document.getElementById(bgId); + let overlay = document.getElementById(overlayId); + + if (!backgroundContainer) { + backgroundContainer = document.createElement('div'); + backgroundContainer.id = bgId; + backgroundContainer.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: 100% auto; + background-position: top center; + background-repeat: no-repeat; + z-index: 1; + pointer-events: none; + `; + targetContainer.insertBefore(backgroundContainer, targetContainer.firstChild); + } + + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = overlayId; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, ${settings.opacity}); + z-index: 2; + pointer-events: none; + `; + targetContainer.insertBefore(overlay, targetContainer.firstChild); + } + + backgroundContainer.style.backgroundImage = `url("${imageUrl}")`; + overlay.style.backgroundColor = `rgba(0, 0, 0, ${settings.opacity})`; + + targetContainer.style.position = 'relative'; + + const chatElement = document.getElementById('chat'); + if (chatElement) { + chatElement.style.cssText += ` + background-color: transparent !important; + background-image: none !important; + background: transparent !important; + position: relative; + z-index: 3; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: none !important; + border: none !important; + text-shadow: none !important; + opacity: 1 !important; + `; + } + applyMessageStyling(); + } +} + +function isMessageComplete(messageElement) { + const regenerateBtn = messageElement.querySelector('.mes_regenerate'); + const editBtn = messageElement.querySelector('.mes_edit'); + const hasButtons = regenerateBtn || editBtn; + + const mesText = messageElement.querySelector('.mes_text'); + const hasContent = mesText && mesText.textContent.trim().length > 0; + + const hasStreamingIndicator = messageElement.querySelector('.typing_indicator') || + messageElement.querySelector('.mes_loading') || + messageElement.classList.contains('streaming'); + + return hasButtons && hasContent && !hasStreamingIndicator; +} + +function getContentHash(text) { + let hash = 0; + if (text.length === 0) return hash; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(); +} + +function shouldProcessMessage(messageId, messageText) { + const contentHash = getContentHash(messageText); + const storedHash = processedMessages.get(messageId); + return !storedHash || storedHash !== contentHash; +} + +function markMessageProcessed(messageId, messageText) { + const contentHash = getContentHash(messageText); + processedMessages.set(messageId, contentHash); +} + +async function handleAIMessage(data) { + if (!isActive() || isProcessing) return; + + try { + isProcessing = true; + + const messageId = data.messageId || data; + if (!messageId) return; + + const messageElement = document.querySelector(`div.mes[mesid="${messageId}"]`); + if (!messageElement || messageElement.classList.contains('is_user')) return; + + let retryCount = 0; + const maxRetries = 10; + + while (retryCount < maxRetries) { + if (isMessageComplete(messageElement)) { + break; + } + retryCount++; + await new Promise(resolve => setTimeout(resolve, 1000)); + + if (!isActive()) return; + } + + const mesText = messageElement.querySelector('.mes_text'); + if (!mesText) return; + + const messageText = mesText.textContent || ''; + if (!messageText.trim() || messageText.length < 10) return; + + if (!shouldProcessMessage(messageId, messageText)) { + return; + } + + markMessageProcessed(messageId, messageText); + + const settings = getWallhavenSettings(); + + showProgressInMessageHeader(messageElement, '提取标签中...'); + + const result = extractTagsFromText(messageText, settings.bgMode); + if (result.tags.length === 0) { + updateProgressText('未提取到标签'); + setTimeout(removeProgressFromMessageHeader, 2000); + return; + } + + if (!isActive()) return; + + const orientation = isLandscapeOrientation() ? '横屏' : '竖屏'; + const modeText = settings.bgMode ? '背景' : '角色'; + const totalWeight = result.tags.reduce((sum, tagObj) => sum + tagObj.weight, 0); + const customCount = result.tags.filter(t => t.category === 'custom').length; + updateProgressText(`${orientation}${modeText}:提取到 ${result.tags.length} 个标签 (自定义${customCount}个,总权重${totalWeight})`); + await new Promise(resolve => setTimeout(resolve, 500)); + + if (!isActive()) return; + + const selectedImage = await intelligentTagMatching(result.tags, settings); + + if (!isActive()) return; + + updateProgressText('应用背景中...'); + + const imageUrl = `https://wallhaven.velure.top/?url=${encodeURIComponent(selectedImage.path)}`; + + applyBackgroundToApp(imageUrl, settings); + + const coreTagsCount = selectedImage.matchedTags.filter(t => t.weight >= 2).length; + const customMatchCount = selectedImage.matchedTags.filter(t => t.category === 'custom').length; + updateProgressText(`${modeText}配图完成! 核心匹配${coreTagsCount}个 自定义${customMatchCount}个 权重${selectedImage.weightedScore}`); + setTimeout(removeProgressFromMessageHeader, 2000); + + } catch (error) { + updateProgressText(`配图失败: ${error.message.length > 20 ? error.message.substring(0, 20) + '...' : error.message}`); + setTimeout(removeProgressFromMessageHeader, 3000); + } finally { + isProcessing = false; + } +} + +function updateSettingsControls() { + const settings = getWallhavenSettings(); + $('#wallhaven_enabled').prop('checked', settings.enabled); + $('#wallhaven_bg_mode').prop('checked', settings.bgMode); + $('#wallhaven_category').val(settings.category); + $('#wallhaven_purity').val(settings.purity); + $('#wallhaven_opacity').val(settings.opacity); + $('#wallhaven_opacity_value').text(Math.round(settings.opacity * 100) + '%'); + + // 控制后续设置的显示/隐藏 + const settingsContainer = $('#wallhaven_settings_container'); + if (settings.enabled) { + settingsContainer.show(); + } else { + settingsContainer.hide(); + } + + renderCustomTagsList(); +} + +function initSettingsEvents() { + $('#wallhaven_enabled').off('change').on('change', function() { + if (!window.isXiaobaixEnabled) return; + + const settings = getWallhavenSettings(); + const wasEnabled = settings.enabled; + settings.enabled = $(this).prop('checked'); + saveSettingsDebounced(); + + // 控制后续设置的显示/隐藏 + const settingsContainer = $('#wallhaven_settings_container'); + if (settings.enabled) { + settingsContainer.show(); + } else { + settingsContainer.hide(); + } + + if (settings.enabled && !wasEnabled) { + bindMessageHandlers(); + } else if (!settings.enabled && wasEnabled) { + clearBackgroundState(); + removeProgressFromMessageHeader(); + processedMessages.clear(); + isProcessing = false; + unbindMessageHandlers(); + } + }); + + $('#wallhaven_bg_mode').off('change').on('change', function() { + if (!window.isXiaobaixEnabled) return; + const settings = getWallhavenSettings(); + settings.bgMode = $(this).prop('checked'); + saveSettingsDebounced(); + }); + + $('#wallhaven_category').off('change').on('change', function() { + if (!window.isXiaobaixEnabled) return; + const settings = getWallhavenSettings(); + settings.category = $(this).val(); + saveSettingsDebounced(); + }); + + $('#wallhaven_purity').off('change').on('change', function() { + if (!window.isXiaobaixEnabled) return; + const settings = getWallhavenSettings(); + settings.purity = $(this).val(); + saveSettingsDebounced(); + }); + + $('#wallhaven_opacity').off('input').on('input', function() { + if (!window.isXiaobaixEnabled) return; + const settings = getWallhavenSettings(); + settings.opacity = parseFloat($(this).val()); + $('#wallhaven_opacity_value').text(Math.round(settings.opacity * 100) + '%'); + $('#wallhaven-app-overlay, #wallhaven-chat-overlay').css('background-color', `rgba(0, 0, 0, ${settings.opacity})`); + saveSettingsDebounced(); + }); + + $('#wallhaven_add_custom_tag').off('click').on('click', function() { + if (!window.isXiaobaixEnabled) return; + const input = document.getElementById('wallhaven_custom_tag_input'); + const tag = input.value.trim(); + if (tag) { + if (addCustomTag(tag)) { + input.value = ''; + } else { + input.style.borderColor = '#ff6b6b'; + setTimeout(() => { + input.style.borderColor = ''; + }, 1000); + } + } + }); + + $('#wallhaven_custom_tag_input').off('keypress').on('keypress', function(e) { + if (!window.isXiaobaixEnabled) return; + if (e.which === 13) { + $('#wallhaven_add_custom_tag').click(); + } + }); +} + +function bindMessageHandlers() { + messageEvents.cleanup(); + + messageEvents.on(event_types.MESSAGE_RECEIVED, handleAIMessage); + if (event_types.MESSAGE_SWIPED) { + messageEvents.on(event_types.MESSAGE_SWIPED, handleAIMessage); + } + if (event_types.MESSAGE_EDITED) { + messageEvents.on(event_types.MESSAGE_EDITED, handleAIMessage); + } + if (event_types.MESSAGE_UPDATED) { + messageEvents.on(event_types.MESSAGE_UPDATED, handleAIMessage); + } +} + +function unbindMessageHandlers() { + messageEvents.cleanup(); +} + +function handleGlobalStateChange(event) { + const globalEnabled = event.detail.enabled; + + const wallhavenControls = [ + 'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category', + 'wallhaven_purity', 'wallhaven_opacity', 'wallhaven_custom_tag_input', + 'wallhaven_add_custom_tag' + ]; + + wallhavenControls.forEach(id => { + $(`#${id}`).prop('disabled', !globalEnabled).toggleClass('disabled-control', !globalEnabled); + }); + + if (globalEnabled) { + updateSettingsControls(); + initSettingsEvents(); + + if (isActive()) { + bindMessageHandlers(); + } + } else { + clearBackgroundState(); + removeProgressFromMessageHeader(); + processedMessages.clear(); + isProcessing = false; + + unbindMessageHandlers(); + + $('#wallhaven_enabled, #wallhaven_bg_mode, #wallhaven_category, #wallhaven_purity, #wallhaven_opacity, #wallhaven_add_custom_tag').off(); + $('#wallhaven_custom_tag_input').off(); + } +} + +function handleChatChanged() { + processedMessages.clear(); + clearBackgroundState(); + removeProgressFromMessageHeader(); + isProcessing = false; +} + +function initWallhavenBackground() { + const globalEnabled = window.isXiaobaixEnabled !== undefined ? window.isXiaobaixEnabled : true; + + const wallhavenControls = [ + 'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category', + 'wallhaven_purity', 'wallhaven_opacity', 'wallhaven_custom_tag_input', + 'wallhaven_add_custom_tag' + ]; + + wallhavenControls.forEach(id => { + $(`#${id}`).prop('disabled', !globalEnabled).toggleClass('disabled-control', !globalEnabled); + }); + + if (globalEnabled) { + updateSettingsControls(); + initSettingsEvents(); + + if (isActive()) { + bindMessageHandlers(); + } + } + + document.addEventListener('xiaobaixEnabledChanged', handleGlobalStateChange); + globalEvents.on(event_types.CHAT_CHANGED, handleChatChanged); + window.addEventListener('resize', handleWindowResize); + + lastScreenSize = getCurrentScreenSize(); + + return { cleanup }; +} + +function cleanup() { + messageEvents.cleanup(); + globalEvents.cleanup(); + document.removeEventListener('xiaobaixEnabledChanged', handleGlobalStateChange); + window.removeEventListener('resize', handleWindowResize); + + clearBackgroundState(); + removeProgressFromMessageHeader(); + + isProcessing = false; + processedMessages.clear(); + currentProgressButton = null; + currentImageUrl = null; + currentSettings = null; +} + +export { initWallhavenBackground }; diff --git a/settings.html b/settings.html index e187a39..f2a1f91 100644 --- a/settings.html +++ b/settings.html @@ -1,264 +1,264 @@ -
-
- 小白X -
-
-
-
-
- - - - -
-
-
-
总开关
-
-
- - - -
-
- - - - -
-
渲染模式 -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
流式,非基础的渲染 -
-
-
- - -
-
-
-
当前角色模板设置
-
- -
-
-
- 请选择一个角色 -
-
-
功能说明 -
-
-
- - - -
-
- - - -
-
-
-
+
+
+ 小白X +
+
+
+
+
+ + + + +
+
+
+
总开关
+
+
+ + + +
+
+ + + + +
+
渲染模式 +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
流式,非基础的渲染 +
+
+
+ + +
+
+
+
当前角色模板设置
+
+ +
+
+
+ 请选择一个角色 +
+
+
功能说明 +
+
+
+ + + +
+
+ + + +
+
+
+