diff --git a/README.md b/README.md index f650a53..023cafa 100644 --- a/README.md +++ b/README.md @@ -5,66 +5,81 @@ 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 # 声明 +LittleWhiteBox/ +├── index.js # 主入口,初始化所有模块,管理总开关 +├── manifest.json # 插件清单,版本、依赖声明 +├── settings.html # 主设置页面,所有模块开关UI +├── style.css # 全局样式 +├── README.md # 说明文档 +│ +├── core/ # 核心公共模块 +│ ├── constants.js # 共享常量 EXT_ID, extensionFolderPath +│ ├── event-manager.js # 统一事件管理,createModuleEvents() +│ ├── debug-core.js # 日志 xbLog + 缓存注册 CacheRegistry +│ ├── slash-command.js # 斜杠命令执行封装 +│ ├── variable-path.js # 变量路径解析工具 +│ └── server-storage.js # 服务器文件存储,防抖保存,自动重试 +│ +├── modules/ # 功能模块 +│ ├── button-collapse.js # 按钮折叠,消息区按钮收纳 +│ ├── control-audio.js # 音频控制,iframe音频权限 +│ ├── iframe-renderer.js # iframe渲染,代码块转交互界面 +│ ├── immersive-mode.js # 沉浸模式,界面布局优化 +│ ├── message-preview.js # 消息预览,Log记录/拦截 +│ ├── script-assistant.js # 脚本助手,AI写卡知识注入 +│ ├── streaming-generation.js # 流式生成,xbgenraw命令 +│ │ +│ ├── debug-panel/ # 调试面板模块 +│ │ ├── debug-panel.js # 悬浮窗控制,父子通信,懒加载 +│ │ └── debug-panel.html # 三Tab界面:日志/事件/缓存 +│ │ +│ ├── fourth-wall/ # 四次元壁模块(皮下交流) +│ │ ├── fourth-wall.js # 悬浮按钮,postMessage通讯 +│ │ └── fourth-wall.html # iframe聊天界面,提示词编辑 +│ │ +│ ├── novel-draw/ # Novel画图模块 +│ │ ├── novel-draw.js # NovelAI画图,预设管理,LLM场景分析 +│ │ ├── novel-draw.html # 参数配置,图片管理(画廊+缓存) +│ │ ├── floating-panel.js # 悬浮面板,状态显示,快捷操作 +│ │ └── gallery-cache.js # IndexedDB缓存,小画廊UI +│ │ +│ ├── scheduled-tasks/ # 定时任务模块 +│ │ ├── scheduled-tasks.js # 全局/角色/预设任务调度 +│ │ ├── scheduled-tasks.html # 任务设置面板 +│ │ └── embedded-tasks.html # 嵌入式任务界面 +│ │ +│ ├── template-editor/ # 模板编辑器模块 +│ │ ├── template-editor.js # 沉浸式模板,流式多楼层渲染 +│ │ └── template-editor.html # 模板编辑界面 +│ │ +│ ├── story-outline/ # 故事大纲模块 +│ │ ├── story-outline.js # 可视化剧情地图 +│ │ ├── story-outline.html # 大纲编辑界面 +│ │ └── story-outline-prompt.js # 大纲生成提示词 +│ │ +│ ├── story-summary/ # 剧情总结模块 +│ │ ├── story-summary.js # 增量总结,时间线,关系图 +│ │ └── story-summary.html # 总结面板界面 +│ │ +│ └── variables/ # 变量系统模块 +│ ├── var-commands.js # /xbgetvar /xbsetvar 命令,宏替换 +│ ├── varevent-editor.js # 条件规则编辑器,varevent运行时 +│ ├── variables-core.js # plot-log解析,快照回滚,变量守护 +│ └── variables-panel.js # 变量面板UI +│ +├── bridges/ # 外部服务桥接 +│ ├── call-generate-service.js # 父窗口:调用ST生成服务 +│ ├── worldbook-bridge.js # 父窗口:世界书读写桥接 +│ └── wrapper-iframe.js # iframe内部:提供CallGenerate API +│ +└── docs/ # 文档与许可 + ├── script-docs.md # 脚本文档 + ├── COPYRIGHT # 版权声明 + ├── LICENSE.md # 许可证 + └── NOTICE # 通知 + ``` -## 📝 模块组织规则 - -- **单文件模块**:直接放在 `modules/` 目录下 -- **多文件模块**:创建子目录,包含相关的 JS、HTML 等文件 -- **桥接模块**:与外部系统交互的独立模块放在 `bridges/` -- **避免使用 `index.js`**:每个模块文件直接命名,不使用 `index.js` - ## 🔄 版本历史 - v2.2.2 - 目录结构重构(2025-12-08) diff --git a/core/server-storage.js b/core/server-storage.js index dae0c27..e7bfdbc 100644 --- a/core/server-storage.js +++ b/core/server-storage.js @@ -137,3 +137,4 @@ class StorageFile { export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json'); export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json'); +export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 }); \ No newline at end of file diff --git a/index.js b/index.js index c86c989..87576a1 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,3 @@ -// =========================================================================== -// Imports -// =========================================================================== - import { extension_settings, getContext } from "../../../extensions.js"; import { saveSettingsDebounced, eventSource, event_types, getRequestHeaders } from "../../../../script.js"; import { EXT_ID, EXT_NAME, extensionFolderPath } from "./core/constants.js"; @@ -12,7 +8,6 @@ import { initScriptAssistant } from "./modules/script-assistant.js"; import { initMessagePreview, addHistoryButtonsDebounced } from "./modules/message-preview.js"; import { initImmersiveMode } from "./modules/immersive-mode.js"; import { initTemplateEditor, templateSettings } from "./modules/template-editor/template-editor.js"; -import { initWallhavenBackground } from "./modules/wallhaven-background.js"; import { initFourthWall, fourthWallCleanup } from "./modules/fourth-wall/fourth-wall.js"; import { initButtonCollapse } from "./modules/button-collapse.js"; import { initVariablesPanel, getVariablesPanelInstance, cleanupVariablesPanel } from "./modules/variables/variables-panel.js"; @@ -35,10 +30,6 @@ 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"; extension_settings[EXT_ID] = extension_settings[EXT_ID] || { @@ -49,7 +40,6 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || { tasks: { enabled: true, globalTasks: [], processedMessages: [], character_allowed_tasks: [] }, scriptAssistant: { enabled: false }, preview: { enabled: false }, - wallhaven: { enabled: false }, immersive: { enabled: false }, fourthWall: { enabled: false }, audio: { enabled: true }, @@ -67,10 +57,6 @@ 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', 'promptSections', @@ -97,10 +83,6 @@ function cleanupDeprecatedData() { } } -// =========================================================================== -// State Variables -// =========================================================================== - let isXiaobaixEnabled = settings.enabled; let moduleCleanupFunctions = new Map(); let updateCheckPerformed = false; @@ -117,10 +99,6 @@ window.testRemoveUpdateUI = () => { removeAllUpdateNotices(); }; -// =========================================================================== -// Update Check -// =========================================================================== - async function checkLittleWhiteBoxUpdate() { try { const timestamp = Date.now(); @@ -246,10 +224,6 @@ async function performExtensionUpdateCheck() { } catch (error) {} } -// =========================================================================== -// Module Cleanup Registration -// =========================================================================== - function registerModuleCleanup(moduleName, cleanupFunction) { moduleCleanupFunctions.set(moduleName, cleanupFunction); } @@ -283,22 +257,9 @@ function cleanupAllResources() { btn.style.display = 'none'; } }); - document.getElementById('xiaobaix-hide-code')?.remove(); - document.body.classList.remove('xiaobaix-active'); - document.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - delete pre.dataset.xbFinal; - pre.style.display = ''; - delete pre.dataset.xiaobaixBound; - }); removeSkeletonStyles(); } -// =========================================================================== -// Utility Functions -// =========================================================================== - async function waitForElement(selector, root = document, timeout = 10000) { const start = Date.now(); while (Date.now() - start < timeout) { @@ -309,16 +270,10 @@ async function waitForElement(selector, root = document, timeout = 10000) { return null; } -// =========================================================================== -// Settings Controls Toggle -// =========================================================================== - function toggleSettingsControls(enabled) { const controls = [ 'xiaobaix_sandbox', 'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled', 'xiaobaix_script_assistant', 'scheduled_tasks_enabled', 'xiaobaix_template_enabled', - 'wallhaven_enabled', 'wallhaven_bg_mode', 'wallhaven_category', - 'wallhaven_purity', 'wallhaven_opacity', 'xiaobaix_immersive_enabled', 'xiaobaix_fourth_wall_enabled', 'xiaobaix_audio_enabled', 'xiaobaix_variables_panel_enabled', 'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'Wrapperiframe', 'xiaobaix_render_enabled', @@ -339,37 +294,8 @@ function toggleSettingsControls(enabled) { } } -function ensureHideCodeStyle(enable) { - const id = 'xiaobaix-hide-code'; - const old = document.getElementById(id); - if (!enable) { - old?.remove(); - return; - } - if (old) return; - const hideCodeStyle = document.createElement('style'); - hideCodeStyle.id = id; - hideCodeStyle.textContent = ` - .xiaobaix-active .mes_text pre { display: none !important; } - .xiaobaix-active .mes_text pre.xb-show { display: block !important; } - `; - document.head.appendChild(hideCodeStyle); -} - -function setActiveClass(enable) { - document.body.classList.toggle('xiaobaix-active', !!enable); -} - -// =========================================================================== -// Toggle All Features -// =========================================================================== - async function toggleAllFeatures(enabled) { if (enabled) { - if (settings.renderEnabled !== false) { - ensureHideCodeStyle(true); - setActiveClass(true); - } toggleSettingsControls(true); try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) {} saveSettingsDebounced(); @@ -383,7 +309,6 @@ async function toggleAllFeatures(enabled) { { condition: extension_settings[EXT_ID].scriptAssistant?.enabled, init: initScriptAssistant }, { condition: extension_settings[EXT_ID].immersive?.enabled, init: initImmersiveMode }, { condition: extension_settings[EXT_ID].templateEditor?.enabled, init: initTemplateEditor }, - { condition: extension_settings[EXT_ID].wallhaven?.enabled, init: initWallhavenBackground }, { condition: extension_settings[EXT_ID].fourthWall?.enabled, init: initFourthWall }, { condition: extension_settings[EXT_ID].variablesPanel?.enabled, init: initVariablesPanel }, { condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore }, @@ -426,15 +351,6 @@ async function toggleAllFeatures(enabled) { try { cleanupNovelDraw(); } catch (e) {} try { clearBlobCaches(); } catch (e) {} toggleSettingsControls(false); - document.getElementById('xiaobaix-hide-code')?.remove(); - setActiveClass(false); - document.querySelectorAll('pre[data-xiaobaix-bound="true"]').forEach(pre => { - pre.classList.remove('xb-show'); - pre.removeAttribute('data-xbfinal'); - delete pre.dataset.xbFinal; - pre.style.display = ''; - delete pre.dataset.xiaobaixBound; - }); window.removeScriptDocs?.(); try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) {} try { window.cleanupCallGenerateHostBridge && window.cleanupCallGenerateHostBridge(); document.getElementById('xb-callgen')?.remove(); } catch (e) {} @@ -443,10 +359,6 @@ async function toggleAllFeatures(enabled) { } } -// =========================================================================== -// Settings Panel Setup -// =========================================================================== - async function setupSettings() { try { const settingsContainer = await waitForElement("#extensions_settings"); @@ -483,7 +395,6 @@ async function setupSettings() { { id: 'xiaobaix_script_assistant', key: 'scriptAssistant', init: initScriptAssistant }, { id: 'scheduled_tasks_enabled', key: 'tasks', init: initTasks }, { id: 'xiaobaix_template_enabled', key: 'templateEditor', init: initTemplateEditor }, - { id: 'wallhaven_enabled', key: 'wallhaven', init: initWallhavenBackground }, { id: 'xiaobaix_fourth_wall_enabled', key: 'fourthWall', init: initFourthWall }, { id: 'xiaobaix_variables_panel_enabled', key: 'variablesPanel', init: initVariablesPanel }, { id: 'xiaobaix_variables_core_enabled', key: 'variablesCore', init: initVariablesCore }, @@ -550,12 +461,9 @@ async function setupSettings() { settings.renderEnabled = $(this).prop("checked"); saveSettingsDebounced(); if (!settings.renderEnabled && wasEnabled) { - document.getElementById('xiaobaix-hide-code')?.remove(); - document.body.classList.remove('xiaobaix-active'); - invalidateAll(); + cleanupRenderer(); } else if (settings.renderEnabled && !wasEnabled) { - ensureHideCodeStyle(true); - document.body.classList.add('xiaobaix-active'); + initRenderer(); setTimeout(() => processExistingMessages(), 100); } }); @@ -588,14 +496,13 @@ async function setupSettings() { scriptAssistant: 'xiaobaix_script_assistant', tasks: 'scheduled_tasks_enabled', templateEditor: 'xiaobaix_template_enabled', - wallhaven: 'wallhaven_enabled', fourthWall: 'xiaobaix_fourth_wall_enabled', variablesPanel: 'xiaobaix_variables_panel_enabled', variablesCore: 'xiaobaix_variables_core_enabled', novelDraw: 'xiaobaix_novel_draw_enabled' }; const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; - const OFF = ['preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw']; + const OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw']; function setChecked(id, val) { const el = document.getElementById(id); if (el) { @@ -648,10 +555,6 @@ function setupDebugButtonInSettings() { } catch (e) {} } -// =========================================================================== -// Menu Tabs -// =========================================================================== - function setupMenuTabs() { $(document).on('click', '.menu-tab', function () { const targetId = $(this).attr('data-target'); @@ -668,31 +571,18 @@ function setupMenuTabs() { }, 300); } -// =========================================================================== -// Global Exports -// =========================================================================== - window.processExistingMessages = processExistingMessages; window.renderHtmlInIframe = renderHtmlInIframe; window.registerModuleCleanup = registerModuleCleanup; window.updateLittleWhiteBoxExtension = updateLittleWhiteBoxExtension; window.removeAllUpdateNotices = removeAllUpdateNotices; -// =========================================================================== -// Entry Point -// =========================================================================== - jQuery(async () => { try { cleanupDeprecatedData(); isXiaobaixEnabled = settings.enabled; window.isXiaobaixEnabled = isXiaobaixEnabled; - if (isXiaobaixEnabled && settings.renderEnabled !== false) { - ensureHideCodeStyle(true); - setActiveClass(true); - } - if (!document.getElementById('xiaobaix-skeleton-style')) { const skelStyle = document.createElement('style'); skelStyle.id = 'xiaobaix-skeleton-style'; @@ -739,7 +629,6 @@ jQuery(async () => { { condition: settings.scriptAssistant?.enabled, init: initScriptAssistant }, { condition: settings.immersive?.enabled, init: initImmersiveMode }, { condition: settings.templateEditor?.enabled, init: initTemplateEditor }, - { condition: settings.wallhaven?.enabled, init: initWallhavenBackground }, { condition: settings.fourthWall?.enabled, init: initFourthWall }, { condition: settings.variablesPanel?.enabled, init: initVariablesPanel }, { condition: settings.variablesCore?.enabled, init: initVariablesCore }, diff --git a/manifest.json b/manifest.json index ad6ee22..e78e91b 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,6 @@ "css": "style.css", "author": "biex", "version": "2.3.1", - "homePage": "https://github.com/RT15548/LittleWhiteBox" - -} + "homePage": "https://github.com/RT15548/LittleWhiteBox" , + "generate_interceptor": "xiaobaixGenerateInterceptor" +} \ No newline at end of file diff --git a/modules/fourth-wall/fourth-wall.html b/modules/fourth-wall/fourth-wall.html index 97fdc20..bd86a8e 100644 --- a/modules/fourth-wall/fourth-wall.html +++ b/modules/fourth-wall/fourth-wall.html @@ -199,17 +199,93 @@ html, body { .fw-streaming { opacity: 0.8; font-style: italic; } .fw-empty { text-align: center; color: var(--text-muted); padding: 40px; font-size: 0.875rem; } -.fw-img-slot { margin: 8px 0; } -.fw-img-slot img { max-width: min(300px, 70vw); max-height: 50vh; border-radius: 8px; display: block; } -.fw-img-loading { font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 6px; } - -.fw-img-error { - width: 200px; height: 140px; background: var(--bg-tertiary); - border: 1px dashed var(--border-color); border-radius: 8px; - display: flex; flex-direction: column; align-items: center; justify-content: center; - color: var(--text-muted); font-size: 0.75rem; +/* 图片懒加载样式 */ +.fw-img-slot { + margin: 8px 0; + min-height: 80px; + position: relative; } +.fw-img-slot img { + max-width: min(300px, 70vw); + max-height: 50vh; + border-radius: 8px; + display: block; + cursor: pointer; + transition: opacity 0.2s; +} + +.fw-img-slot img:hover { opacity: 0.9; } + +.fw-img-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 16px; + background: var(--bg-tertiary); + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-muted); + font-size: 0.75rem; +} + +.fw-img-placeholder i { font-size: 24px; opacity: 0.4; } + +.fw-img-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px; + background: linear-gradient(135deg, rgba(76,154,255,0.08), rgba(118,75,162,0.08)); + border: 1px solid rgba(76,154,255,0.15); + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.fw-img-error { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px; + background: rgba(248,113,113,0.08); + border: 1px dashed rgba(248,113,113,0.25); + border-radius: 8px; + color: #f87171; + font-size: 0.75rem; + text-align: center; +} + +.fw-img-retry { + margin-top: 4px; + padding: 4px 12px; + background: rgba(248,113,113,0.15); + border: 1px solid rgba(248,113,113,0.25); + border-radius: 4px; + color: #f87171; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.2s; +} + +.fw-img-retry:hover { background: rgba(248,113,113,0.25); } + +.fw-img-badge { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0,0,0,0.6); + color: #fbbf24; + font-size: 10px; + padding: 3px 6px; + border-radius: 4px; + backdrop-filter: blur(4px); +} + +/* 语音样式 */ .fw-voice-bubble { display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); @@ -331,7 +407,6 @@ html, body { .fw-prompt-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; } -/* 思考折叠UI - 一体化卡片设计 */ .fw-thinking-card { margin-bottom: 6px; background: rgba(0,0,0,0.03); @@ -363,9 +438,7 @@ html, body { transition: transform 0.2s; } -.fw-thinking-header.expanded .chevron { - transform: rotate(90deg); -} +.fw-thinking-header.expanded .chevron { transform: rotate(90deg); } .fw-thinking-body { display: none; @@ -391,7 +464,6 @@ html, body { .fw-thinking-body::-webkit-scrollbar { width: 4px; } .fw-thinking-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; } -/* 流式思考指示器 */ .fw-thinking-header.streaming span::after { content: ''; display: inline-block; @@ -408,18 +480,9 @@ html, body { 50% { opacity: 1; } } -/* 编辑模式宽度最大化 */ -.fw-row.editing { - max-width: 100%; -} - -.fw-row.editing .fw-bubble { - width: 100%; -} - -.fw-row.editing .fw-edit-area { - min-height: 80px; -} +.fw-row.editing { max-width: 100%; } +.fw-row.editing .fw-bubble { width: 100%; } +.fw-row.editing .fw-edit-area { min-height: 80px; } @media (max-width: 600px) { .fw-header { padding: 6px 12px; } @@ -431,6 +494,12 @@ html, body { .fw-bubble { padding: 8px 12px; font-size: 0.875rem; } .fw-avatar { width: 32px; height: 32px; } } + +@media (max-width: 480px) { + .fw-container { padding: 0; } + .fw-title { font-size: 0.875rem; } + .fw-btn { padding: 4px 8px; font-size: 0.7rem; } +} @@ -489,16 +558,9 @@ html, body {
媒体
-
- - -
- +
@@ -548,7 +610,7 @@ html, body {
实时吐槽
- +
@@ -609,22 +671,17 @@ html, body { - + \ No newline at end of file diff --git a/modules/fourth-wall/fourth-wall.js b/modules/fourth-wall/fourth-wall.js index cd5d9db..84eda6f 100644 --- a/modules/fourth-wall/fourth-wall.js +++ b/modules/fourth-wall/fourth-wall.js @@ -5,7 +5,9 @@ import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; import { createModuleEvents, event_types } from "../../core/event-manager.js"; import { xbLog } from "../../core/debug-core.js"; -// ================== 常量定义 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 常量定义 +// ════════════════════════════════════════════════════════════════════════════ const events = createModuleEvents('fourthWall'); const iframePath = `${extensionFolderPath}/modules/fourth-wall/fourth-wall.html`; @@ -14,11 +16,11 @@ const COMMENTARY_COOLDOWN = 180000; const IMG_GUIDELINE = `## 模拟图片 如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟: -[image: Person/Subject, Appearance/Clothing, Background/Environment, Atmosphere/Lighting, Extra descriptors] -- tag必须为英文,用逗号分隔,使用Wallhaven常见、可用的tag组合,5-8个tag -- 第一个tag须固定为这四个人物标签之一:[boy, girl, man, woman] +[image: Subject, Appearance, Background, Atmosphere, Extra descriptors] +- tag必须为英文,用逗号分隔,使用Danbooru风格的tag,5-15个tag +- 第一个tag须固定为人物数量标签,如: 1girl, 1boy, 2girls, solo, etc. - 可以多张照片: 每行一张 [image: ...] -- 模拟社交软件发图的真实感,当需要发送的内容尺度较大时必须加上nsfw:前缀,即[image: nsfw: ...] +- 当需要发送的内容尺度较大时加上nsfw相关tag - image部分也需要在内`; const VOICE_GUIDELINE = `## 模拟语音 @@ -29,11 +31,6 @@ const VOICE_GUIDELINE = `## 模拟语音 - ……省略号:拖长音、犹豫、伤感 - !感叹号:语气有力、激动 - ?问号:疑问语调、尾音上扬 -### 示例: -[voice: 你好,今天天气真好。] 普通 -[voice: 我……不太确定……] 犹豫/拖长 -[voice: 太好了!我成功了!] 激动 -[voice: 你确定吗?] 疑问 - voice部分也需要在内`; const DEFAULT_META_PROTOCOL = ` @@ -120,7 +117,9 @@ const COMMENTARY_PROTOCOL = ` 只输出一个...块。不要添加任何其他格式 `; -// ================== 状态变量 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 状态变量 +// ════════════════════════════════════════════════════════════════════════════ let overlayCreated = false; let frameReady = false; @@ -135,28 +134,98 @@ let lastCommentaryTime = 0; let commentaryBubbleEl = null; let commentaryBubbleTimer = null; -// ================== 设置管理 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 图片缓存 (IndexedDB) +// ════════════════════════════════════════════════════════════════════════════ + +const FW_IMG_DB_NAME = 'xb_fourth_wall_images'; +const FW_IMG_DB_STORE = 'images'; +const FW_IMG_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; + +let fwImgDb = null; + +async function openFWImgDB() { + if (fwImgDb) return fwImgDb; + return new Promise((resolve, reject) => { + const request = indexedDB.open(FW_IMG_DB_NAME, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { fwImgDb = request.result; resolve(fwImgDb); }; + request.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(FW_IMG_DB_STORE)) { + db.createObjectStore(FW_IMG_DB_STORE, { keyPath: 'hash' }); + } + }; + }); +} + +function hashTags(tags) { + let hash = 0; + const str = String(tags || '').toLowerCase().replace(/\s+/g, ' ').trim(); + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return 'fw_' + Math.abs(hash).toString(36); +} + +async function getCachedImage(tags) { + try { + const db = await openFWImgDB(); + const hash = hashTags(tags); + return new Promise((resolve) => { + const tx = db.transaction(FW_IMG_DB_STORE, 'readonly'); + const req = tx.objectStore(FW_IMG_DB_STORE).get(hash); + req.onsuccess = () => { + const result = req.result; + if (result && Date.now() - result.timestamp < FW_IMG_CACHE_TTL) { + resolve(result.base64); + } else { + resolve(null); + } + }; + req.onerror = () => resolve(null); + }); + } catch { return null; } +} + +async function cacheImage(tags, base64) { + try { + const db = await openFWImgDB(); + const hash = hashTags(tags); + const tx = db.transaction(FW_IMG_DB_STORE, 'readwrite'); + tx.objectStore(FW_IMG_DB_STORE).put({ hash, tags, base64, timestamp: Date.now() }); + } catch {} +} + +async function clearExpiredFWImageCache() { + try { + const db = await openFWImgDB(); + const cutoff = Date.now() - FW_IMG_CACHE_TTL; + const tx = db.transaction(FW_IMG_DB_STORE, 'readwrite'); + const store = tx.objectStore(FW_IMG_DB_STORE); + store.openCursor().onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + if (cursor.value.timestamp < cutoff) cursor.delete(); + cursor.continue(); + } + }; + } catch {} +} + +// ════════════════════════════════════════════════════════════════════════════ +// 设置管理 +// ════════════════════════════════════════════════════════════════════════════ function getSettings() { extension_settings[EXT_ID] ||= {}; const s = extension_settings[EXT_ID]; s.fourthWall ||= { enabled: true }; - s.fourthWallImage ||= { - categoryPreference: 'anime', - purityDefault: '111', - purityWhenNSFW: '001', - enablePrompt: false, - }; - s.fourthWallVoice ||= { - enabled: false, - voice: '桃夭', - speed: 0.5, - }; - s.fourthWallCommentary ||= { - enabled: false, - probability: 30 - }; + s.fourthWallImage ||= { enablePrompt: false }; + s.fourthWallVoice ||= { enabled: false, voice: '桃夭', speed: 0.5 }; + s.fourthWallCommentary ||= { enabled: false, probability: 30 }; s.fourthWallPromptTemplates ||= {}; const t = s.fourthWallPromptTemplates; @@ -173,23 +242,17 @@ Scene_Description_Requirements: - Symbolism_and_Implication: Use personification and symbolism to add depth and subtlety to scenes. `; } - if (t.confirm === undefined) { - t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。'; - } - if (t.bottom === undefined) { - t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照内要求,进行互动,开始内省:`; - } - if (t.metaProtocol === undefined) { - t.metaProtocol = DEFAULT_META_PROTOCOL; - } - if (t.imgGuideline === undefined) { - t.imgGuideline = IMG_GUIDELINE; - } + if (t.confirm === undefined) t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。'; + if (t.bottom === undefined) t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照内要求,进行互动,开始内省:`; + if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL; + if (t.imgGuideline === undefined) t.imgGuideline = IMG_GUIDELINE; return s; } -// ================== 工具函数 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 工具函数 +// ════════════════════════════════════════════════════════════════════════════ function b64UrlEncode(str) { const utf8 = new TextEncoder().encode(String(str)); @@ -301,10 +364,7 @@ function getAvatarUrls() { } return ''; }; - let user = pickSrc([ - '#user_avatar_block img', '#avatar_user img', '.user_avatar img', - 'img#avatar_user', '.st-user-avatar img' - ]) || (typeof default_user_avatar !== 'undefined' ? default_user_avatar : ''); + let user = pickSrc(['#user_avatar_block img', '#avatar_user img', '.user_avatar img', 'img#avatar_user', '.st-user-avatar img']) || (typeof default_user_avatar !== 'undefined' ? default_user_avatar : ''); const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i); if (m) user = `User Avatars/${decodeURIComponent(m[1])}`; const ctx = getContext?.() || {}; @@ -336,7 +396,9 @@ async function getUserAndCharNames() { return { userName, charName }; } -// ================== 存储管理 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 存储管理 +// ════════════════════════════════════════════════════════════════════════════ function getFWStore(chatId = getCurrentChatIdSafe()) { if (!chatId) return null; @@ -371,7 +433,9 @@ function saveFWStore() { saveMetadataDebounced?.(); } -// ================== iframe 通讯 ================== +// ════════════════════════════════════════════════════════════════════════════ +// iframe 通讯 +// ════════════════════════════════════════════════════════════════════════════ function postToFrame(payload) { const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); @@ -410,6 +474,59 @@ function sendInitData() { }); } +// ════════════════════════════════════════════════════════════════════════════ +// NovelDraw 图片生成 (带缓存) +// ════════════════════════════════════════════════════════════════════════════ + +async function handleCheckImageCache(data) { + const { requestId, tags } = data; + const cached = await getCachedImage(tags); + if (cached) { + postToFrame({ type: 'IMAGE_RESULT', requestId, base64: cached, fromCache: true }); + } else { + postToFrame({ type: 'CACHE_MISS', requestId, tags }); + } +} + +async function handleGenerateImage(data) { + const { requestId, tags } = data; + + const novelDraw = window.xiaobaixNovelDraw; + if (!novelDraw) { + postToFrame({ type: 'IMAGE_RESULT', requestId, error: 'NovelDraw 模块未启用' }); + return; + } + + try { + const settings = novelDraw.getSettings(); + const paramsPreset = settings.paramsPresets?.find(p => p.id === settings.selectedParamsPresetId) || settings.paramsPresets?.[0]; + + if (!paramsPreset) { + postToFrame({ type: 'IMAGE_RESULT', requestId, error: '无可用的参数预设' }); + return; + } + + const positive = [paramsPreset.positivePrefix, tags].filter(Boolean).join(', '); + + const base64 = await novelDraw.generateNovelImage({ + prompt: positive, + negativePrompt: paramsPreset.negativePrefix || '', + params: paramsPreset.params || {}, + characters: [] + }); + + await cacheImage(tags, base64); + postToFrame({ type: 'IMAGE_RESULT', requestId, base64 }); + } catch (e) { + console.error('[FourthWall] 图片生成失败:', e); + postToFrame({ type: 'IMAGE_RESULT', requestId, error: e.message || '生成失败' }); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// 消息处理 +// ════════════════════════════════════════════════════════════════════════════ + function handleFrameMessage(event) { const data = event.data; if (!data || data.source !== 'LittleWhiteBox-FourthWall') return; @@ -529,10 +646,20 @@ function handleFrameMessage(event) { case 'CLOSE_OVERLAY': hideOverlay(); break; + + case 'CHECK_IMAGE_CACHE': + handleCheckImageCache(data); + break; + + case 'GENERATE_IMAGE': + handleGenerateImage(data); + break; } } -// ================== Prompt 构建 ================== +// ════════════════════════════════════════════════════════════════════════════ +// Prompt 构建 +// ════════════════════════════════════════════════════════════════════════════ async function buildPrompt(userInput, history, settings, imgSettings, voiceSettings, isCommentary = false) { const { userName, charName } = await getUserAndCharNames(); @@ -579,10 +706,7 @@ async function buildPrompt(userInput, history, settings, imgSettings, voiceSetti }) .join('\n'); - const msg1 = String(T.topuser || '') - .replace(/{{USER_NAME}}/g, userName) - .replace(/{{CHAR_NAME}}/g, charName); - + const msg1 = String(T.topuser || '').replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName); const msg2 = String(T.confirm || '好的,我已阅读设置要求,准备查看历史并进入角色。'); let metaProtocol = (isCommentary ? COMMENTARY_PROTOCOL : String(T.metaProtocol || '')).replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName); @@ -604,7 +728,9 @@ ${metaProtocol}`.replace(/\|/g, '|').trim(); return { msg1, msg2, msg3, msg4 }; } -// ================== 生成处理 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 生成处理 +// ════════════════════════════════════════════════════════════════════════════ async function handleSendMessage(data) { if (isStreaming) return; @@ -616,25 +742,15 @@ async function handleSendMessage(data) { saveFWStore(); } - const { msg1, msg2, msg3, msg4 } = await buildPrompt( - data.userInput, - data.history, - data.settings, - data.imgSettings, - data.voiceSettings - ); - + const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings); const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`); const nonstreamArg = data.settings.stream ? '' : ' nonstream=true'; const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`; try { await executeSlashCommand(cmd); - if (data.settings.stream) { - startStreamingPoll(); - } else { - startNonstreamAwait(); - } + if (data.settings.stream) startStreamingPoll(); + else startNonstreamAwait(); } catch { stopStreamingPoll(); isStreaming = false; @@ -652,25 +768,15 @@ async function handleRegenerate(data) { saveFWStore(); } - const { msg1, msg2, msg3, msg4 } = await buildPrompt( - data.userInput, - data.history, - data.settings, - data.imgSettings, - data.voiceSettings - ); - + const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings); const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`); const nonstreamArg = data.settings.stream ? '' : ' nonstream=true'; const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`; try { await executeSlashCommand(cmd); - if (data.settings.stream) { - startStreamingPoll(); - } else { - startNonstreamAwait(); - } + if (data.settings.stream) startStreamingPoll(); + else startNonstreamAwait(); } catch { stopStreamingPoll(); isStreaming = false; @@ -687,16 +793,10 @@ function startStreamingPoll() { const raw = gen.getLastGeneration(STREAM_SESSION_ID) || '...'; const thinking = extractThinkingPartial(raw); const msg = extractMsg(raw) || extractMsgPartial(raw); - postToFrame({ - type: 'STREAM_UPDATE', - text: msg || '...', - thinking: thinking || undefined - }); + postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined }); const st = gen.getStatus?.(STREAM_SESSION_ID); - if (st && st.isStreaming === false) { - finalizeGeneration(); - } + if (st && st.isStreaming === false) finalizeGeneration(); }, 80); } @@ -705,9 +805,7 @@ function startNonstreamAwait() { streamTimerId = setInterval(() => { const gen = window.xiaobaixStreamingGeneration; const st = gen?.getStatus?.(STREAM_SESSION_ID); - if (st && st.isStreaming === false) { - finalizeGeneration(); - } + if (st && st.isStreaming === false) finalizeGeneration(); }, 120); } @@ -729,12 +827,7 @@ function finalizeGeneration() { const session = getActiveSession(); if (session) { - session.history.push({ - role: 'ai', - content: finalText, - thinking: thinkingText || undefined, - ts: Date.now() - }); + session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() }); saveFWStore(); } @@ -749,7 +842,9 @@ function cancelGeneration() { postToFrame({ type: 'GENERATION_CANCELLED' }); } -// ================== 实时吐槽 ================== +// ════════════════════════════════════════════════════════════════════════════ +// 实时吐槽 +// ════════════════════════════════════════════════════════════════════════════ function shouldTriggerCommentary() { const settings = getSettings(); @@ -766,25 +861,15 @@ async function buildCommentaryPrompt(targetText, type) { const session = getActiveSession(); if (!store || !session) return null; - const { msg1, msg2, msg3 } = await buildPrompt( - '', - session.history || [], - store.settings || {}, - settings.fourthWallImage || {}, - settings.fourthWallVoice || {}, - true - ); + const { msg1, msg2, msg3 } = await buildPrompt('', session.history || [], store.settings || {}, settings.fourthWallImage || {}, settings.fourthWallVoice || {}, true); let msg4; if (type === 'ai_message') { - msg4 = `现在剧本还在继续中,我刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。 -我将直接输出内容:`; + msg4 = `现在剧本还在继续中,我刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。我将直接输出内容:`; } else if (type === 'edit_own') { - msg4 = `现在剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}」 -必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出内容:`; + msg4 = `现在剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}」必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出内容:`; } else if (type === 'edit_ai') { - msg4 = `现在剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}」 -必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出内容:。`; + msg4 = `现在剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}」必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出内容:。`; } return { msg1, msg2, msg3, msg4 }; @@ -794,7 +879,6 @@ async function generateCommentary(targetText, type) { const built = await buildCommentaryPrompt(targetText, type); if (!built) return null; const { msg1, msg2, msg3, msg4 } = built; - const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`); try { @@ -874,16 +958,8 @@ function getFloatBtnPosition() { if (!btn) return null; const rect = btn.getBoundingClientRect(); let stored = {}; - try { - stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; - } catch {} - return { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - side: stored.side || 'right' - }; + try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch {} + return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' }; } function showCommentaryBubble(text) { @@ -895,19 +971,9 @@ function showCommentaryBubble(text) { bubble.textContent = text; bubble.onclick = hideCommentaryBubble; Object.assign(bubble.style, { - position: 'fixed', - zIndex: '10000', - maxWidth: '200px', - padding: '8px 12px', - background: 'rgba(255,255,255,0.95)', - borderRadius: '12px', - boxShadow: '0 4px 20px rgba(0,0,0,0.15)', - fontSize: '13px', - color: '#333', - cursor: 'pointer', - opacity: '0', - transform: 'scale(0.8)', - transition: 'opacity 0.3s, transform 0.3s' + position: 'fixed', zIndex: '10000', maxWidth: '200px', padding: '8px 12px', + background: 'rgba(255,255,255,0.95)', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0,0,0,0.15)', + fontSize: '13px', color: '#333', cursor: 'pointer', opacity: '0', transform: 'scale(0.8)', transition: 'opacity 0.3s, transform 0.3s' }); document.body.appendChild(bubble); commentaryBubbleEl = bubble; @@ -930,10 +996,7 @@ function showCommentaryBubble(text) { bubble.style.right = ''; bubble.style.borderBottomLeftRadius = '4px'; } - requestAnimationFrame(() => { - bubble.style.opacity = '1'; - bubble.style.transform = 'scale(1)'; - }); + requestAnimationFrame(() => { bubble.style.opacity = '1'; bubble.style.transform = 'scale(1)'; }); const len = (text || '').length; const duration = Math.min(2000 + Math.ceil(len / 5) * 1000, 8000); commentaryBubbleTimer = setTimeout(hideCommentaryBubble, duration); @@ -941,17 +1004,11 @@ function showCommentaryBubble(text) { } function hideCommentaryBubble() { - if (commentaryBubbleTimer) { - clearTimeout(commentaryBubbleTimer); - commentaryBubbleTimer = null; - } + if (commentaryBubbleTimer) { clearTimeout(commentaryBubbleTimer); commentaryBubbleTimer = null; } if (commentaryBubbleEl) { commentaryBubbleEl.style.opacity = '0'; commentaryBubbleEl.style.transform = 'scale(0.8)'; - setTimeout(() => { - commentaryBubbleEl?.remove(); - commentaryBubbleEl = null; - }, 300); + setTimeout(() => { commentaryBubbleEl?.remove(); commentaryBubbleEl = null; }, 300); } } @@ -967,7 +1024,9 @@ function cleanupCommentary() { lastCommentaryTime = 0; } -// ================== Overlay 管理 ================== +// ════════════════════════════════════════════════════════════════════════════ +// Overlay 管理 +// ════════════════════════════════════════════════════════════════════════════ function createOverlay() { if (overlayCreated) return; @@ -979,26 +1038,10 @@ function createOverlay() { const framePadding = isMobile ? 'padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important;' : ''; const $overlay = $(` -