Add files via upload
This commit is contained in:
115
README.md
115
README.md
@@ -6,65 +6,80 @@ SillyTavern 扩展插件 - 小白X
|
|||||||
|
|
||||||
```
|
```
|
||||||
LittleWhiteBox/
|
LittleWhiteBox/
|
||||||
├── manifest.json # 插件配置清单
|
├── index.js # 主入口,初始化所有模块,管理总开关
|
||||||
├── index.js # 主入口文件
|
├── manifest.json # 插件清单,版本、依赖声明
|
||||||
├── settings.html # 设置页面模板
|
├── settings.html # 主设置页面,所有模块开关UI
|
||||||
├── style.css # 全局样式
|
├── style.css # 全局样式
|
||||||
|
├── README.md # 说明文档
|
||||||
│
|
│
|
||||||
├── modules/ # 功能模块目录
|
├── core/ # 核心公共模块
|
||||||
│ ├── streaming-generation.js # 流式生成
|
│ ├── constants.js # 共享常量 EXT_ID, extensionFolderPath
|
||||||
│ ├── dynamic-prompt.js # 动态提示词
|
│ ├── event-manager.js # 统一事件管理,createModuleEvents()
|
||||||
│ ├── immersive-mode.js # 沉浸模式
|
│ ├── debug-core.js # 日志 xbLog + 缓存注册 CacheRegistry
|
||||||
│ ├── message-preview.js # 消息预览
|
│ ├── slash-command.js # 斜杠命令执行封装
|
||||||
│ ├── wallhaven-background.js # 壁纸背景
|
│ ├── variable-path.js # 变量路径解析工具
|
||||||
│ ├── button-collapse.js # 按钮折叠
|
│ └── server-storage.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/ # 外部桥接模块
|
├── modules/ # 功能模块
|
||||||
│ ├── worldbook-bridge.js # 世界书桥接
|
│ ├── button-collapse.js # 按钮折叠,消息区按钮收纳
|
||||||
│ ├── call-generate-service.js # 生成服务调用
|
│ ├── control-audio.js # 音频控制,iframe音频权限
|
||||||
│ └── wrapper-iframe.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
|
||||||
│
|
│
|
||||||
├── ui/ # UI 模板
|
├── bridges/ # 外部服务桥接
|
||||||
│ └── character-updater-menus.html
|
│ ├── call-generate-service.js # 父窗口:调用ST生成服务
|
||||||
|
│ ├── worldbook-bridge.js # 父窗口:世界书读写桥接
|
||||||
|
│ └── wrapper-iframe.js # iframe内部:提供CallGenerate API
|
||||||
│
|
│
|
||||||
└── docs/ # 文档
|
└── docs/ # 文档与许可
|
||||||
├── script-docs.md # 脚本文档
|
├── script-docs.md # 脚本文档
|
||||||
|
├── COPYRIGHT # 版权声明
|
||||||
├── LICENSE.md # 许可证
|
├── LICENSE.md # 许可证
|
||||||
├── COPYRIGHT # 版权信息
|
└── NOTICE # 通知
|
||||||
└── NOTICE # 声明
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 模块组织规则
|
|
||||||
|
|
||||||
- **单文件模块**:直接放在 `modules/` 目录下
|
|
||||||
- **多文件模块**:创建子目录,包含相关的 JS、HTML 等文件
|
|
||||||
- **桥接模块**:与外部系统交互的独立模块放在 `bridges/`
|
|
||||||
- **避免使用 `index.js`**:每个模块文件直接命名,不使用 `index.js`
|
|
||||||
|
|
||||||
## 🔄 版本历史
|
## 🔄 版本历史
|
||||||
|
|
||||||
- v2.2.2 - 目录结构重构(2025-12-08)
|
- v2.2.2 - 目录结构重构(2025-12-08)
|
||||||
|
|||||||
@@ -137,3 +137,4 @@ class StorageFile {
|
|||||||
|
|
||||||
export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json');
|
export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json');
|
||||||
export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json');
|
export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json');
|
||||||
|
export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 });
|
||||||
117
index.js
117
index.js
@@ -1,7 +1,3 @@
|
|||||||
// ===========================================================================
|
|
||||||
// Imports
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
import { extension_settings, getContext } from "../../../extensions.js";
|
import { extension_settings, getContext } from "../../../extensions.js";
|
||||||
import { saveSettingsDebounced, eventSource, event_types, getRequestHeaders } from "../../../../script.js";
|
import { saveSettingsDebounced, eventSource, event_types, getRequestHeaders } from "../../../../script.js";
|
||||||
import { EXT_ID, EXT_NAME, extensionFolderPath } from "./core/constants.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 { initMessagePreview, addHistoryButtonsDebounced } from "./modules/message-preview.js";
|
||||||
import { initImmersiveMode } from "./modules/immersive-mode.js";
|
import { initImmersiveMode } from "./modules/immersive-mode.js";
|
||||||
import { initTemplateEditor, templateSettings } from "./modules/template-editor/template-editor.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 { initFourthWall, fourthWallCleanup } from "./modules/fourth-wall/fourth-wall.js";
|
||||||
import { initButtonCollapse } from "./modules/button-collapse.js";
|
import { initButtonCollapse } from "./modules/button-collapse.js";
|
||||||
import { initVariablesPanel, getVariablesPanelInstance, cleanupVariablesPanel } from "./modules/variables/variables-panel.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-summary/story-summary.js";
|
||||||
import "./modules/story-outline/story-outline.js";
|
import "./modules/story-outline/story-outline.js";
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Constants and Default Settings
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
const MODULE_NAME = "xiaobaix-memory";
|
const MODULE_NAME = "xiaobaix-memory";
|
||||||
|
|
||||||
extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
|
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: [] },
|
tasks: { enabled: true, globalTasks: [], processedMessages: [], character_allowed_tasks: [] },
|
||||||
scriptAssistant: { enabled: false },
|
scriptAssistant: { enabled: false },
|
||||||
preview: { enabled: false },
|
preview: { enabled: false },
|
||||||
wallhaven: { enabled: false },
|
|
||||||
immersive: { enabled: false },
|
immersive: { enabled: false },
|
||||||
fourthWall: { enabled: false },
|
fourthWall: { enabled: false },
|
||||||
audio: { enabled: true },
|
audio: { enabled: true },
|
||||||
@@ -67,10 +57,6 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
|
|||||||
const settings = extension_settings[EXT_ID];
|
const settings = extension_settings[EXT_ID];
|
||||||
if (settings.dynamicPrompt && !settings.fourthWall) settings.fourthWall = settings.dynamicPrompt;
|
if (settings.dynamicPrompt && !settings.fourthWall) settings.fourthWall = settings.dynamicPrompt;
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Deprecated Data Cleanup
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
const DEPRECATED_KEYS = [
|
const DEPRECATED_KEYS = [
|
||||||
'characterUpdater',
|
'characterUpdater',
|
||||||
'promptSections',
|
'promptSections',
|
||||||
@@ -97,10 +83,6 @@ function cleanupDeprecatedData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// State Variables
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
let isXiaobaixEnabled = settings.enabled;
|
let isXiaobaixEnabled = settings.enabled;
|
||||||
let moduleCleanupFunctions = new Map();
|
let moduleCleanupFunctions = new Map();
|
||||||
let updateCheckPerformed = false;
|
let updateCheckPerformed = false;
|
||||||
@@ -117,10 +99,6 @@ window.testRemoveUpdateUI = () => {
|
|||||||
removeAllUpdateNotices();
|
removeAllUpdateNotices();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Update Check
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
async function checkLittleWhiteBoxUpdate() {
|
async function checkLittleWhiteBoxUpdate() {
|
||||||
try {
|
try {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
@@ -246,10 +224,6 @@ async function performExtensionUpdateCheck() {
|
|||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Module Cleanup Registration
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
function registerModuleCleanup(moduleName, cleanupFunction) {
|
function registerModuleCleanup(moduleName, cleanupFunction) {
|
||||||
moduleCleanupFunctions.set(moduleName, cleanupFunction);
|
moduleCleanupFunctions.set(moduleName, cleanupFunction);
|
||||||
}
|
}
|
||||||
@@ -283,22 +257,9 @@ function cleanupAllResources() {
|
|||||||
btn.style.display = 'none';
|
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();
|
removeSkeletonStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Utility Functions
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
async function waitForElement(selector, root = document, timeout = 10000) {
|
async function waitForElement(selector, root = document, timeout = 10000) {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeout) {
|
while (Date.now() - start < timeout) {
|
||||||
@@ -309,16 +270,10 @@ async function waitForElement(selector, root = document, timeout = 10000) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Settings Controls Toggle
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
function toggleSettingsControls(enabled) {
|
function toggleSettingsControls(enabled) {
|
||||||
const controls = [
|
const controls = [
|
||||||
'xiaobaix_sandbox', 'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled',
|
'xiaobaix_sandbox', 'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled',
|
||||||
'xiaobaix_script_assistant', 'scheduled_tasks_enabled', 'xiaobaix_template_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_immersive_enabled', 'xiaobaix_fourth_wall_enabled',
|
||||||
'xiaobaix_audio_enabled', 'xiaobaix_variables_panel_enabled',
|
'xiaobaix_audio_enabled', 'xiaobaix_variables_panel_enabled',
|
||||||
'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'Wrapperiframe', 'xiaobaix_render_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) {
|
async function toggleAllFeatures(enabled) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (settings.renderEnabled !== false) {
|
|
||||||
ensureHideCodeStyle(true);
|
|
||||||
setActiveClass(true);
|
|
||||||
}
|
|
||||||
toggleSettingsControls(true);
|
toggleSettingsControls(true);
|
||||||
try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) {}
|
try { window.XB_applyPrevStates && window.XB_applyPrevStates(); } catch (e) {}
|
||||||
saveSettingsDebounced();
|
saveSettingsDebounced();
|
||||||
@@ -383,7 +309,6 @@ async function toggleAllFeatures(enabled) {
|
|||||||
{ condition: extension_settings[EXT_ID].scriptAssistant?.enabled, init: initScriptAssistant },
|
{ condition: extension_settings[EXT_ID].scriptAssistant?.enabled, init: initScriptAssistant },
|
||||||
{ condition: extension_settings[EXT_ID].immersive?.enabled, init: initImmersiveMode },
|
{ condition: extension_settings[EXT_ID].immersive?.enabled, init: initImmersiveMode },
|
||||||
{ condition: extension_settings[EXT_ID].templateEditor?.enabled, init: initTemplateEditor },
|
{ 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].fourthWall?.enabled, init: initFourthWall },
|
||||||
{ condition: extension_settings[EXT_ID].variablesPanel?.enabled, init: initVariablesPanel },
|
{ condition: extension_settings[EXT_ID].variablesPanel?.enabled, init: initVariablesPanel },
|
||||||
{ condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore },
|
{ condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore },
|
||||||
@@ -426,15 +351,6 @@ async function toggleAllFeatures(enabled) {
|
|||||||
try { cleanupNovelDraw(); } catch (e) {}
|
try { cleanupNovelDraw(); } catch (e) {}
|
||||||
try { clearBlobCaches(); } catch (e) {}
|
try { clearBlobCaches(); } catch (e) {}
|
||||||
toggleSettingsControls(false);
|
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?.();
|
window.removeScriptDocs?.();
|
||||||
try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) {}
|
try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) {}
|
||||||
try { window.cleanupCallGenerateHostBridge && window.cleanupCallGenerateHostBridge(); document.getElementById('xb-callgen')?.remove(); } catch (e) {}
|
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() {
|
async function setupSettings() {
|
||||||
try {
|
try {
|
||||||
const settingsContainer = await waitForElement("#extensions_settings");
|
const settingsContainer = await waitForElement("#extensions_settings");
|
||||||
@@ -483,7 +395,6 @@ async function setupSettings() {
|
|||||||
{ id: 'xiaobaix_script_assistant', key: 'scriptAssistant', init: initScriptAssistant },
|
{ id: 'xiaobaix_script_assistant', key: 'scriptAssistant', init: initScriptAssistant },
|
||||||
{ id: 'scheduled_tasks_enabled', key: 'tasks', init: initTasks },
|
{ id: 'scheduled_tasks_enabled', key: 'tasks', init: initTasks },
|
||||||
{ id: 'xiaobaix_template_enabled', key: 'templateEditor', init: initTemplateEditor },
|
{ 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_fourth_wall_enabled', key: 'fourthWall', init: initFourthWall },
|
||||||
{ id: 'xiaobaix_variables_panel_enabled', key: 'variablesPanel', init: initVariablesPanel },
|
{ id: 'xiaobaix_variables_panel_enabled', key: 'variablesPanel', init: initVariablesPanel },
|
||||||
{ id: 'xiaobaix_variables_core_enabled', key: 'variablesCore', init: initVariablesCore },
|
{ id: 'xiaobaix_variables_core_enabled', key: 'variablesCore', init: initVariablesCore },
|
||||||
@@ -550,12 +461,9 @@ async function setupSettings() {
|
|||||||
settings.renderEnabled = $(this).prop("checked");
|
settings.renderEnabled = $(this).prop("checked");
|
||||||
saveSettingsDebounced();
|
saveSettingsDebounced();
|
||||||
if (!settings.renderEnabled && wasEnabled) {
|
if (!settings.renderEnabled && wasEnabled) {
|
||||||
document.getElementById('xiaobaix-hide-code')?.remove();
|
cleanupRenderer();
|
||||||
document.body.classList.remove('xiaobaix-active');
|
|
||||||
invalidateAll();
|
|
||||||
} else if (settings.renderEnabled && !wasEnabled) {
|
} else if (settings.renderEnabled && !wasEnabled) {
|
||||||
ensureHideCodeStyle(true);
|
initRenderer();
|
||||||
document.body.classList.add('xiaobaix-active');
|
|
||||||
setTimeout(() => processExistingMessages(), 100);
|
setTimeout(() => processExistingMessages(), 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -588,14 +496,13 @@ async function setupSettings() {
|
|||||||
scriptAssistant: 'xiaobaix_script_assistant',
|
scriptAssistant: 'xiaobaix_script_assistant',
|
||||||
tasks: 'scheduled_tasks_enabled',
|
tasks: 'scheduled_tasks_enabled',
|
||||||
templateEditor: 'xiaobaix_template_enabled',
|
templateEditor: 'xiaobaix_template_enabled',
|
||||||
wallhaven: 'wallhaven_enabled',
|
|
||||||
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
||||||
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
||||||
variablesCore: 'xiaobaix_variables_core_enabled',
|
variablesCore: 'xiaobaix_variables_core_enabled',
|
||||||
novelDraw: 'xiaobaix_novel_draw_enabled'
|
novelDraw: 'xiaobaix_novel_draw_enabled'
|
||||||
};
|
};
|
||||||
const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
|
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) {
|
function setChecked(id, val) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -648,10 +555,6 @@ function setupDebugButtonInSettings() {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Menu Tabs
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
function setupMenuTabs() {
|
function setupMenuTabs() {
|
||||||
$(document).on('click', '.menu-tab', function () {
|
$(document).on('click', '.menu-tab', function () {
|
||||||
const targetId = $(this).attr('data-target');
|
const targetId = $(this).attr('data-target');
|
||||||
@@ -668,31 +571,18 @@ function setupMenuTabs() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Global Exports
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
window.processExistingMessages = processExistingMessages;
|
window.processExistingMessages = processExistingMessages;
|
||||||
window.renderHtmlInIframe = renderHtmlInIframe;
|
window.renderHtmlInIframe = renderHtmlInIframe;
|
||||||
window.registerModuleCleanup = registerModuleCleanup;
|
window.registerModuleCleanup = registerModuleCleanup;
|
||||||
window.updateLittleWhiteBoxExtension = updateLittleWhiteBoxExtension;
|
window.updateLittleWhiteBoxExtension = updateLittleWhiteBoxExtension;
|
||||||
window.removeAllUpdateNotices = removeAllUpdateNotices;
|
window.removeAllUpdateNotices = removeAllUpdateNotices;
|
||||||
|
|
||||||
// ===========================================================================
|
|
||||||
// Entry Point
|
|
||||||
// ===========================================================================
|
|
||||||
|
|
||||||
jQuery(async () => {
|
jQuery(async () => {
|
||||||
try {
|
try {
|
||||||
cleanupDeprecatedData();
|
cleanupDeprecatedData();
|
||||||
isXiaobaixEnabled = settings.enabled;
|
isXiaobaixEnabled = settings.enabled;
|
||||||
window.isXiaobaixEnabled = isXiaobaixEnabled;
|
window.isXiaobaixEnabled = isXiaobaixEnabled;
|
||||||
|
|
||||||
if (isXiaobaixEnabled && settings.renderEnabled !== false) {
|
|
||||||
ensureHideCodeStyle(true);
|
|
||||||
setActiveClass(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!document.getElementById('xiaobaix-skeleton-style')) {
|
if (!document.getElementById('xiaobaix-skeleton-style')) {
|
||||||
const skelStyle = document.createElement('style');
|
const skelStyle = document.createElement('style');
|
||||||
skelStyle.id = 'xiaobaix-skeleton-style';
|
skelStyle.id = 'xiaobaix-skeleton-style';
|
||||||
@@ -739,7 +629,6 @@ jQuery(async () => {
|
|||||||
{ condition: settings.scriptAssistant?.enabled, init: initScriptAssistant },
|
{ condition: settings.scriptAssistant?.enabled, init: initScriptAssistant },
|
||||||
{ condition: settings.immersive?.enabled, init: initImmersiveMode },
|
{ condition: settings.immersive?.enabled, init: initImmersiveMode },
|
||||||
{ condition: settings.templateEditor?.enabled, init: initTemplateEditor },
|
{ condition: settings.templateEditor?.enabled, init: initTemplateEditor },
|
||||||
{ condition: settings.wallhaven?.enabled, init: initWallhavenBackground },
|
|
||||||
{ condition: settings.fourthWall?.enabled, init: initFourthWall },
|
{ condition: settings.fourthWall?.enabled, init: initFourthWall },
|
||||||
{ condition: settings.variablesPanel?.enabled, init: initVariablesPanel },
|
{ condition: settings.variablesPanel?.enabled, init: initVariablesPanel },
|
||||||
{ condition: settings.variablesCore?.enabled, init: initVariablesCore },
|
{ condition: settings.variablesCore?.enabled, init: initVariablesCore },
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"css": "style.css",
|
"css": "style.css",
|
||||||
"author": "biex",
|
"author": "biex",
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"homePage": "https://github.com/RT15548/LittleWhiteBox"
|
"homePage": "https://github.com/RT15548/LittleWhiteBox"
|
||||||
|
,
|
||||||
"generate_interceptor": "xiaobaixGenerateInterceptor"
|
"generate_interceptor": "xiaobaixGenerateInterceptor"
|
||||||
@@ -199,17 +199,93 @@ html, body {
|
|||||||
.fw-streaming { opacity: 0.8; font-style: italic; }
|
.fw-streaming { opacity: 0.8; font-style: italic; }
|
||||||
.fw-empty { text-align: center; color: var(--text-muted); padding: 40px; font-size: 0.875rem; }
|
.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-slot {
|
||||||
.fw-img-loading { font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
|
margin: 8px 0;
|
||||||
|
min-height: 80px;
|
||||||
.fw-img-error {
|
position: relative;
|
||||||
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 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 {
|
.fw-voice-bubble {
|
||||||
display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
|
display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||||
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
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; }
|
.fw-prompt-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
/* 思考折叠UI - 一体化卡片设计 */
|
|
||||||
.fw-thinking-card {
|
.fw-thinking-card {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
background: rgba(0,0,0,0.03);
|
background: rgba(0,0,0,0.03);
|
||||||
@@ -363,9 +438,7 @@ html, body {
|
|||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fw-thinking-header.expanded .chevron {
|
.fw-thinking-header.expanded .chevron { transform: rotate(90deg); }
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fw-thinking-body {
|
.fw-thinking-body {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -391,7 +464,6 @@ html, body {
|
|||||||
.fw-thinking-body::-webkit-scrollbar { width: 4px; }
|
.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-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; }
|
||||||
|
|
||||||
/* 流式思考指示器 */
|
|
||||||
.fw-thinking-header.streaming span::after {
|
.fw-thinking-header.streaming span::after {
|
||||||
content: '';
|
content: '';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -408,18 +480,9 @@ html, body {
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑模式宽度最大化 */
|
.fw-row.editing { max-width: 100%; }
|
||||||
.fw-row.editing {
|
.fw-row.editing .fw-bubble { width: 100%; }
|
||||||
max-width: 100%;
|
.fw-row.editing .fw-edit-area { min-height: 80px; }
|
||||||
}
|
|
||||||
|
|
||||||
.fw-row.editing .fw-bubble {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fw-row.editing .fw-edit-area {
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.fw-header { padding: 6px 12px; }
|
.fw-header { padding: 6px 12px; }
|
||||||
@@ -431,6 +494,12 @@ html, body {
|
|||||||
.fw-bubble { padding: 8px 12px; font-size: 0.875rem; }
|
.fw-bubble { padding: 8px 12px; font-size: 0.875rem; }
|
||||||
.fw-avatar { width: 32px; height: 32px; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -489,16 +558,9 @@ html, body {
|
|||||||
|
|
||||||
<div class="fw-settings-group-title"><i class="fa-solid fa-photo-film"></i>媒体</div>
|
<div class="fw-settings-group-title"><i class="fa-solid fa-photo-film"></i>媒体</div>
|
||||||
<div class="fw-settings-row">
|
<div class="fw-settings-row">
|
||||||
<div class="fw-field">
|
|
||||||
<label>图像类型</label>
|
|
||||||
<select id="img-kind">
|
|
||||||
<option value="anime">动漫</option>
|
|
||||||
<option value="people">真人</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="fw-field">
|
<div class="fw-field">
|
||||||
<input type="checkbox" id="img-prompt-enabled">
|
<input type="checkbox" id="img-prompt-enabled">
|
||||||
<label for="img-prompt-enabled">允许发图</label>
|
<label for="img-prompt-enabled">允许发图 (需开启插件NovelAI画图)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fw-settings-row">
|
<div class="fw-settings-row">
|
||||||
@@ -548,7 +610,7 @@ html, body {
|
|||||||
<div class="fw-settings-group-title"><i class="fa-solid fa-comment-dots"></i>实时吐槽</div>
|
<div class="fw-settings-group-title"><i class="fa-solid fa-comment-dots"></i>实时吐槽</div>
|
||||||
<div class="fw-settings-row">
|
<div class="fw-settings-row">
|
||||||
<div class="fw-field">
|
<div class="fw-field">
|
||||||
<input type="checkbox" id="commentary-enabled" checked>
|
<input type="checkbox" id="commentary-enabled">
|
||||||
<label for="commentary-enabled">实时吐槽</label>
|
<label for="commentary-enabled">实时吐槽</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="fw-field fw-commentary-prob-wrap">
|
<div class="fw-field fw-commentary-prob-wrap">
|
||||||
@@ -609,22 +671,17 @@ html, body {
|
|||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderThinking(text) {
|
function renderThinking(text) {
|
||||||
return String(text || '')
|
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
.replace(/&/g, '&')
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/^[\\*\\-]\\s+/gm, '• ').replace(/\n/g, '<br>');
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/^[\\*\\-]\\s+/gm, '• ')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFullscreenButton(isFullscreen) {
|
function updateFullscreenButton(isFullscreen) {
|
||||||
@@ -632,34 +689,10 @@ function updateFullscreenButton(isFullscreen) {
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const icon = btn.querySelector('i');
|
const icon = btn.querySelector('i');
|
||||||
if (!icon) return;
|
if (!icon) return;
|
||||||
if (isFullscreen) {
|
icon.className = isFullscreen ? 'fa-solid fa-compress' : 'fa-solid fa-expand';
|
||||||
icon.className = 'fa-solid fa-compress';
|
btn.title = isFullscreen ? '退出全屏' : '全屏';
|
||||||
btn.title = '退出全屏';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fa-solid fa-expand';
|
|
||||||
btn.title = '全屏';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TTS_WORKER = 'https://tts.velure.top';
|
|
||||||
|
|
||||||
let state = {
|
|
||||||
history: [],
|
|
||||||
isStreaming: false,
|
|
||||||
editingIndex: null,
|
|
||||||
menuExpanded: false,
|
|
||||||
settingsOpen: false,
|
|
||||||
settings: { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true },
|
|
||||||
sessions: [],
|
|
||||||
activeSessionId: null,
|
|
||||||
imgSettings: { categoryPreference: 'anime', enablePrompt: false },
|
|
||||||
voiceSettings: { enabled: false, voice: '桃夭', speed: 0.8 },
|
|
||||||
commentarySettings: { enabled: true, probability: 30 },
|
|
||||||
promptTemplates: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentAudio = null;
|
|
||||||
|
|
||||||
function formatTimeDisplay(ts) {
|
function formatTimeDisplay(ts) {
|
||||||
if (!ts) return '';
|
if (!ts) return '';
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
@@ -678,60 +711,169 @@ function postToParent(payload) {
|
|||||||
window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, '*');
|
window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
const FW_IMG = { proxy: 'https://wallhaven.velure.top/?url=', maxPickSpan: 24, cacheTTLms: 600000 };
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
const imageCache = new Map();
|
// 状态管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const TTS_WORKER = 'https://tts.velure.top';
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
history: [],
|
||||||
|
isStreaming: false,
|
||||||
|
editingIndex: null,
|
||||||
|
menuExpanded: false,
|
||||||
|
settingsOpen: false,
|
||||||
|
settings: { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true },
|
||||||
|
sessions: [],
|
||||||
|
activeSessionId: null,
|
||||||
|
imgSettings: { enablePrompt: false },
|
||||||
|
voiceSettings: { enabled: false, voice: '桃夭', speed: 0.8 },
|
||||||
|
commentarySettings: { enabled: false, probability: 30 },
|
||||||
|
promptTemplates: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentAudio = null;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 图片懒加载系统
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const pendingImages = new Map();
|
||||||
|
const generatingQueue = new Set();
|
||||||
|
let imageObserver = null;
|
||||||
|
|
||||||
function parseImageToken(rawCSV) {
|
function parseImageToken(rawCSV) {
|
||||||
let txt = String(rawCSV || '').trim();
|
let txt = String(rawCSV || '').trim();
|
||||||
let isNSFW = false;
|
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, 'nsfw, ');
|
||||||
while (true) {
|
return txt.split(',').map(s => s.trim()).filter(Boolean).join(', ');
|
||||||
const m = txt.match(/^(nsfw|sketchy)\s*:\s*/i);
|
|
||||||
if (!m) break;
|
|
||||||
isNSFW = true;
|
|
||||||
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, '');
|
|
||||||
}
|
|
||||||
return { tagCSV: txt.split(',').map(s => s.trim().toLowerCase()).filter(Boolean).join(','), isNSFW };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchWallhaven(tagCSV, { category, purity }) {
|
function initImageObserver() {
|
||||||
const q = tagCSV.split(',').filter(Boolean).join(' ');
|
if (imageObserver) return;
|
||||||
const api = `https://wallhaven.cc/api/v1/search?q=${encodeURIComponent(q)}&categories=${category}&purity=${purity}&ratios=${encodeURIComponent('9x16,10x16,1x1,16x9,16x10,21x9')}&sorting=favorites&page=1`;
|
|
||||||
const res = await fetch(FW_IMG.proxy + encodeURIComponent(api));
|
imageObserver = new IntersectionObserver((entries) => {
|
||||||
if (!res.ok) throw new Error('Search failed');
|
entries.forEach(entry => {
|
||||||
const data = await res.json();
|
if (entry.isIntersecting) {
|
||||||
const list = data?.data || [];
|
const slot = entry.target;
|
||||||
if (list.length) {
|
if (slot.dataset.loaded === '1' || slot.dataset.loading === '1') return;
|
||||||
const pick = list[Math.floor(Math.random() * Math.min(FW_IMG.maxPickSpan, list.length))];
|
|
||||||
return { ok: true, url: FW_IMG.proxy + encodeURIComponent(pick.path) };
|
slot.dataset.loading = '1';
|
||||||
|
const tags = parseImageToken(decodeURIComponent(slot.dataset.raw || ''));
|
||||||
|
if (!tags) return;
|
||||||
|
|
||||||
|
const requestId = 'fwimg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
pendingImages.set(requestId, { slot, tags });
|
||||||
|
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-search"></i> 查询缓存...</div>`;
|
||||||
|
postToParent({ type: 'CHECK_IMAGE_CACHE', requestId, tags });
|
||||||
}
|
}
|
||||||
return { ok: false };
|
});
|
||||||
|
}, {
|
||||||
|
root: document.getElementById('messages'),
|
||||||
|
rootMargin: '150px 0px',
|
||||||
|
threshold: 0.01
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateImageSlots(container) {
|
function hydrateImageSlots(container) {
|
||||||
for (const slot of container.querySelectorAll('.fw-img-slot:not([data-loaded])')) {
|
initImageObserver();
|
||||||
slot.setAttribute('data-loaded', '1');
|
|
||||||
const raw = decodeURIComponent(slot.dataset.raw || '');
|
container.querySelectorAll('.fw-img-slot:not([data-observed])').forEach(slot => {
|
||||||
const { tagCSV, isNSFW } = parseImageToken(raw);
|
slot.setAttribute('data-observed', '1');
|
||||||
if (!tagCSV) continue;
|
|
||||||
const catMap = { anime: '010', people: '001' };
|
if (!slot.dataset.loaded && !slot.dataset.loading) {
|
||||||
const category = catMap[state.imgSettings.categoryPreference] || '010';
|
slot.innerHTML = `
|
||||||
const purity = isNSFW ? '001' : '111';
|
<div class="fw-img-placeholder">
|
||||||
const cacheKey = [tagCSV, purity, category].join('|');
|
<i class="fa-regular fa-image"></i>
|
||||||
try {
|
<span>滚动加载</span>
|
||||||
let rec = imageCache.get(cacheKey);
|
</div>
|
||||||
if (!rec || Date.now() - rec.at > FW_IMG.cacheTTLms) {
|
`;
|
||||||
const result = await searchWallhaven(tagCSV, { category, purity });
|
|
||||||
if (!result.ok) throw new Error('No results');
|
|
||||||
rec = { url: result.url, at: Date.now() };
|
|
||||||
imageCache.set(cacheKey, rec);
|
|
||||||
}
|
}
|
||||||
slot.innerHTML = `<a href="${rec.url}" target="_blank"><img src="${rec.url}" alt="${tagCSV}"></a>`;
|
|
||||||
} catch {
|
imageObserver.observe(slot);
|
||||||
slot.innerHTML = `<div class="fw-img-error"><i class="fa-solid fa-image"></i><div>无法加载图片</div><div style="font-size:10px;">${tagCSV}</div></div>`;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageResult(data) {
|
||||||
|
const pending = pendingImages.get(data.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
const { slot, tags } = pending;
|
||||||
|
pendingImages.delete(data.requestId);
|
||||||
|
generatingQueue.delete(tags);
|
||||||
|
|
||||||
|
slot.dataset.loaded = '1';
|
||||||
|
slot.dataset.loading = '';
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
slot.innerHTML = `
|
||||||
|
<div class="fw-img-error">
|
||||||
|
<i class="fa-solid fa-exclamation-triangle"></i>
|
||||||
|
<div>${escapeHtml(data.error)}</div>
|
||||||
|
<button class="fw-img-retry" data-tags="${encodeURIComponent(tags)}">重试</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
bindRetryButton(slot);
|
||||||
|
} else if (data.base64) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `data:image/png;base64,${data.base64}`;
|
||||||
|
img.alt = 'Generated';
|
||||||
|
img.onclick = () => window.open(img.src, '_blank');
|
||||||
|
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.appendChild(img);
|
||||||
|
|
||||||
|
if (data.fromCache) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'fw-img-badge';
|
||||||
|
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
|
||||||
|
badge.title = '来自缓存';
|
||||||
|
slot.appendChild(badge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCacheMiss(data) {
|
||||||
|
const pending = pendingImages.get(data.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
const { slot, tags } = pending;
|
||||||
|
|
||||||
|
if (generatingQueue.has(tags)) {
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中...</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generatingQueue.add(tags);
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中...</div>`;
|
||||||
|
|
||||||
|
postToParent({ type: 'GENERATE_IMAGE', requestId: data.requestId, tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRetryButton(slot) {
|
||||||
|
const btn = slot.querySelector('.fw-img-retry');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const tags = decodeURIComponent(btn.dataset.tags || '');
|
||||||
|
if (!tags) return;
|
||||||
|
|
||||||
|
slot.dataset.loaded = '';
|
||||||
|
slot.dataset.loading = '1';
|
||||||
|
|
||||||
|
const requestId = 'fwimg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
pendingImages.set(requestId, { slot, tags });
|
||||||
|
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 重新生成...</div>`;
|
||||||
|
postToParent({ type: 'GENERATE_IMAGE', requestId, tags });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 语音处理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async function playVoice(text, bubbleEl) {
|
async function playVoice(text, bubbleEl) {
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
currentAudio.pause();
|
currentAudio.pause();
|
||||||
@@ -781,15 +923,18 @@ function hydrateVoiceSlots(container) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 内容渲染
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function renderContent(text) {
|
function renderContent(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
let html = String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
let html = String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
html = html.replace(/\[(?:image|图片)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
|
html = html.replace(/\[(?:image|图片)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
|
||||||
const { tagCSV } = parseImageToken(inner);
|
const tags = parseImageToken(inner);
|
||||||
if (!tagCSV) return _;
|
if (!tags) return _;
|
||||||
const key = btoa(unescape(encodeURIComponent(tagCSV))).replace(/=+$/, '');
|
return `<div class="fw-img-slot" data-raw="${encodeURIComponent(inner)}"></div>`;
|
||||||
return `<div class="fw-img-slot" data-raw="${encodeURIComponent(inner)}" id="fwimg_${key}"><div class="fw-img-loading"><i class="fa-solid fa-spinner fa-spin"></i> 加载中...</div></div>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
|
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
|
||||||
@@ -805,7 +950,7 @@ function renderContent(text) {
|
|||||||
|
|
||||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||||
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:3px;">$1</code>');
|
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.06);padding:2px 4px;border-radius:3px;">$1</code>');
|
||||||
html = html.replace(/\n/g, '<br>');
|
html = html.replace(/\n/g, '<br>');
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -875,7 +1020,10 @@ function renderMessages() {
|
|||||||
hydrateImageSlots(container);
|
hydrateImageSlots(container);
|
||||||
hydrateVoiceSlots(container);
|
hydrateVoiceSlots(container);
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
|
bindMessageEvents(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindMessageEvents(container) {
|
||||||
container.querySelectorAll('.fw-thinking-header:not(.streaming)').forEach(header => {
|
container.querySelectorAll('.fw-thinking-header:not(.streaming)').forEach(header => {
|
||||||
header.onclick = () => {
|
header.onclick = () => {
|
||||||
const idx = header.dataset.index;
|
const idx = header.dataset.index;
|
||||||
@@ -885,22 +1033,44 @@ function renderMessages() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
container.querySelectorAll('.fw-edit-btn').forEach(btn => {
|
container.querySelectorAll('.fw-edit-btn').forEach(btn => {
|
||||||
btn.onclick = () => { state.editingIndex = parseInt(btn.dataset.index); renderMessages(); const ta = container.querySelector('.fw-edit-area'); if (ta) { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; ta.focus(); } };
|
btn.onclick = () => {
|
||||||
|
state.editingIndex = parseInt(btn.dataset.index);
|
||||||
|
renderMessages();
|
||||||
|
const ta = container.querySelector('.fw-edit-area');
|
||||||
|
if (ta) { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; ta.focus(); }
|
||||||
|
};
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.fw-save-btn').forEach(btn => {
|
container.querySelectorAll('.fw-save-btn').forEach(btn => {
|
||||||
btn.onclick = () => { const idx = parseInt(btn.dataset.index); const ta = container.querySelector(`.fw-edit-area[data-index="${idx}"]`); if (ta) state.history[idx].content = ta.value; state.editingIndex = null; renderMessages(); postToParent({ type: 'SAVE_HISTORY', history: state.history }); };
|
btn.onclick = () => {
|
||||||
|
const idx = parseInt(btn.dataset.index);
|
||||||
|
const ta = container.querySelector(`.fw-edit-area[data-index="${idx}"]`);
|
||||||
|
if (ta) state.history[idx].content = ta.value;
|
||||||
|
state.editingIndex = null;
|
||||||
|
renderMessages();
|
||||||
|
postToParent({ type: 'SAVE_HISTORY', history: state.history });
|
||||||
|
};
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.fw-cancel-btn').forEach(btn => {
|
container.querySelectorAll('.fw-cancel-btn').forEach(btn => {
|
||||||
btn.onclick = () => { state.editingIndex = null; renderMessages(); };
|
btn.onclick = () => { state.editingIndex = null; renderMessages(); };
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.fw-delete-btn').forEach(btn => {
|
container.querySelectorAll('.fw-delete-btn').forEach(btn => {
|
||||||
btn.onclick = () => { if (confirm('确定要删除这条消息吗?')) { state.history.splice(parseInt(btn.dataset.index), 1); renderMessages(); postToParent({ type: 'SAVE_HISTORY', history: state.history }); } };
|
btn.onclick = () => {
|
||||||
|
if (confirm('确定要删除这条消息吗?')) {
|
||||||
|
state.history.splice(parseInt(btn.dataset.index), 1);
|
||||||
|
renderMessages();
|
||||||
|
postToParent({ type: 'SAVE_HISTORY', history: state.history });
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.fw-edit-area').forEach(ta => {
|
container.querySelectorAll('.fw-edit-area').forEach(ta => {
|
||||||
ta.oninput = function() { this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px'; };
|
ta.oninput = function() { this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px'; };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// UI 更新函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function renderSessionSelect() {
|
function renderSessionSelect() {
|
||||||
document.getElementById('session-select').innerHTML = state.sessions.map(s =>
|
document.getElementById('session-select').innerHTML = state.sessions.map(s =>
|
||||||
`<option value="${s.id}" ${s.id === state.activeSessionId ? 'selected' : ''}>${s.name || s.id}</option>`
|
`<option value="${s.id}" ${s.id === state.activeSessionId ? 'selected' : ''}>${s.name || s.id}</option>`
|
||||||
@@ -946,6 +1116,10 @@ function loadPromptFields() {
|
|||||||
document.getElementById('prompt-bottom').value = t.bottom || '';
|
document.getElementById('prompt-bottom').value = t.bottom || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 消息发送
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const textarea = document.getElementById('input-textarea');
|
const textarea = document.getElementById('input-textarea');
|
||||||
const text = textarea.value.trim();
|
const text = textarea.value.trim();
|
||||||
@@ -973,6 +1147,10 @@ function regenerate() {
|
|||||||
postToParent({ type: 'REGENERATE', userInput: lastUserText, history: state.history, settings: state.settings, imgSettings: state.imgSettings, voiceSettings: state.voiceSettings });
|
postToParent({ type: 'REGENERATE', userInput: lastUserText, history: state.history, settings: state.settings, imgSettings: state.imgSettings, voiceSettings: state.voiceSettings });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 消息处理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
window.addEventListener('message', event => {
|
window.addEventListener('message', event => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.source !== 'LittleWhiteBox') return;
|
if (!data || data.source !== 'LittleWhiteBox') return;
|
||||||
@@ -996,7 +1174,6 @@ window.addEventListener('message', event => {
|
|||||||
document.getElementById('layers-input').value = state.settings.maxChatLayers;
|
document.getElementById('layers-input').value = state.settings.maxChatLayers;
|
||||||
document.getElementById('turns-input').value = state.settings.maxMetaTurns;
|
document.getElementById('turns-input').value = state.settings.maxMetaTurns;
|
||||||
document.getElementById('stream-enabled').checked = state.settings.stream;
|
document.getElementById('stream-enabled').checked = state.settings.stream;
|
||||||
document.getElementById('img-kind').value = state.imgSettings.categoryPreference;
|
|
||||||
document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt;
|
document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt;
|
||||||
document.getElementById('voice-enabled').checked = state.voiceSettings.enabled;
|
document.getElementById('voice-enabled').checked = state.voiceSettings.enabled;
|
||||||
document.getElementById('voice-select').value = state.voiceSettings.voice || '桃夭';
|
document.getElementById('voice-select').value = state.voiceSettings.voice || '桃夭';
|
||||||
@@ -1044,15 +1221,27 @@ window.addEventListener('message', event => {
|
|||||||
case 'FULLSCREEN_STATE':
|
case 'FULLSCREEN_STATE':
|
||||||
updateFullscreenButton(data.isFullscreen);
|
updateFullscreenButton(data.isFullscreen);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'IMAGE_RESULT':
|
||||||
|
handleImageResult(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'CACHE_MISS':
|
||||||
|
handleCacheMiss(data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 初始化
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); };
|
document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); };
|
||||||
document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); };
|
document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); };
|
||||||
document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); };
|
document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); };
|
||||||
document.getElementById('btn-close').onclick = () => postToParent({ type: 'CLOSE_OVERLAY' });
|
document.getElementById('btn-close').onclick = () => postToParent({ type: 'CLOSE_OVERLAY' });
|
||||||
document.getElementById('btn-fullscreen').onclick = () => { postToParent({ type: 'TOGGLE_FULLSCREEN' }); };
|
document.getElementById('btn-fullscreen').onclick = () => postToParent({ type: 'TOGGLE_FULLSCREEN' });
|
||||||
|
|
||||||
document.getElementById('btn-reset').onclick = () => {
|
document.getElementById('btn-reset').onclick = () => {
|
||||||
if (confirm('确定要清空当前对话吗?')) { state.history = []; renderMessages(); postToParent({ type: 'RESET_HISTORY' }); }
|
if (confirm('确定要清空当前对话吗?')) { state.history = []; renderMessages(); postToParent({ type: 'RESET_HISTORY' }); }
|
||||||
@@ -1067,8 +1256,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('img-kind').onchange = () => { state.imgSettings.categoryPreference = document.getElementById('img-kind').value; postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings }); };
|
document.getElementById('img-prompt-enabled').onchange = () => {
|
||||||
document.getElementById('img-prompt-enabled').onchange = () => { state.imgSettings.enablePrompt = document.getElementById('img-prompt-enabled').checked; postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings }); };
|
state.imgSettings.enablePrompt = document.getElementById('img-prompt-enabled').checked;
|
||||||
|
postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings });
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('voice-enabled').onchange = function() { state.voiceSettings.enabled = this.checked; updateVoiceUI(this.checked); postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
|
document.getElementById('voice-enabled').onchange = function() { state.voiceSettings.enabled = this.checked; updateVoiceUI(this.checked); postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
|
||||||
document.getElementById('voice-select').onchange = function() { state.voiceSettings.voice = this.value; postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
|
document.getElementById('voice-select').onchange = function() { state.voiceSettings.voice = this.value; postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
|
|||||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||||
import { xbLog } from "../../core/debug-core.js";
|
import { xbLog } from "../../core/debug-core.js";
|
||||||
|
|
||||||
// ================== 常量定义 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 常量定义
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const events = createModuleEvents('fourthWall');
|
const events = createModuleEvents('fourthWall');
|
||||||
const iframePath = `${extensionFolderPath}/modules/fourth-wall/fourth-wall.html`;
|
const iframePath = `${extensionFolderPath}/modules/fourth-wall/fourth-wall.html`;
|
||||||
@@ -14,11 +16,11 @@ const COMMENTARY_COOLDOWN = 180000;
|
|||||||
|
|
||||||
const IMG_GUIDELINE = `## 模拟图片
|
const IMG_GUIDELINE = `## 模拟图片
|
||||||
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
|
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
|
||||||
[image: Person/Subject, Appearance/Clothing, Background/Environment, Atmosphere/Lighting, Extra descriptors]
|
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
|
||||||
- tag必须为英文,用逗号分隔,使用Wallhaven常见、可用的tag组合,5-8个tag
|
- tag必须为英文,用逗号分隔,使用Danbooru风格的tag,5-15个tag
|
||||||
- 第一个tag须固定为这四个人物标签之一:[boy, girl, man, woman]
|
- 第一个tag须固定为人物数量标签,如: 1girl, 1boy, 2girls, solo, etc.
|
||||||
- 可以多张照片: 每行一张 [image: ...]
|
- 可以多张照片: 每行一张 [image: ...]
|
||||||
- 模拟社交软件发图的真实感,当需要发送的内容尺度较大时必须加上nsfw:前缀,即[image: nsfw: ...]
|
- 当需要发送的内容尺度较大时加上nsfw相关tag
|
||||||
- image部分也需要在<msg>内`;
|
- image部分也需要在<msg>内`;
|
||||||
|
|
||||||
const VOICE_GUIDELINE = `## 模拟语音
|
const VOICE_GUIDELINE = `## 模拟语音
|
||||||
@@ -29,11 +31,6 @@ const VOICE_GUIDELINE = `## 模拟语音
|
|||||||
- ……省略号:拖长音、犹豫、伤感
|
- ……省略号:拖长音、犹豫、伤感
|
||||||
- !感叹号:语气有力、激动
|
- !感叹号:语气有力、激动
|
||||||
- ?问号:疑问语调、尾音上扬
|
- ?问号:疑问语调、尾音上扬
|
||||||
### 示例:
|
|
||||||
[voice: 你好,今天天气真好。] 普通
|
|
||||||
[voice: 我……不太确定……] 犹豫/拖长
|
|
||||||
[voice: 太好了!我成功了!] 激动
|
|
||||||
[voice: 你确定吗?] 疑问
|
|
||||||
- voice部分也需要在<msg>内`;
|
- voice部分也需要在<msg>内`;
|
||||||
|
|
||||||
const DEFAULT_META_PROTOCOL = `
|
const DEFAULT_META_PROTOCOL = `
|
||||||
@@ -120,7 +117,9 @@ const COMMENTARY_PROTOCOL = `
|
|||||||
只输出一个<msg>...</msg>块。不要添加任何其他格式
|
只输出一个<msg>...</msg>块。不要添加任何其他格式
|
||||||
</meta_protocol>`;
|
</meta_protocol>`;
|
||||||
|
|
||||||
// ================== 状态变量 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 状态变量
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
let overlayCreated = false;
|
let overlayCreated = false;
|
||||||
let frameReady = false;
|
let frameReady = false;
|
||||||
@@ -135,28 +134,98 @@ let lastCommentaryTime = 0;
|
|||||||
let commentaryBubbleEl = null;
|
let commentaryBubbleEl = null;
|
||||||
let commentaryBubbleTimer = 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() {
|
function getSettings() {
|
||||||
extension_settings[EXT_ID] ||= {};
|
extension_settings[EXT_ID] ||= {};
|
||||||
const s = extension_settings[EXT_ID];
|
const s = extension_settings[EXT_ID];
|
||||||
|
|
||||||
s.fourthWall ||= { enabled: true };
|
s.fourthWall ||= { enabled: true };
|
||||||
s.fourthWallImage ||= {
|
s.fourthWallImage ||= { enablePrompt: false };
|
||||||
categoryPreference: 'anime',
|
s.fourthWallVoice ||= { enabled: false, voice: '桃夭', speed: 0.5 };
|
||||||
purityDefault: '111',
|
s.fourthWallCommentary ||= { enabled: false, probability: 30 };
|
||||||
purityWhenNSFW: '001',
|
|
||||||
enablePrompt: false,
|
|
||||||
};
|
|
||||||
s.fourthWallVoice ||= {
|
|
||||||
enabled: false,
|
|
||||||
voice: '桃夭',
|
|
||||||
speed: 0.5,
|
|
||||||
};
|
|
||||||
s.fourthWallCommentary ||= {
|
|
||||||
enabled: false,
|
|
||||||
probability: 30
|
|
||||||
};
|
|
||||||
s.fourthWallPromptTemplates ||= {};
|
s.fourthWallPromptTemplates ||= {};
|
||||||
|
|
||||||
const t = 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.
|
- Symbolism_and_Implication: Use personification and symbolism to add depth and subtlety to scenes.
|
||||||
</task_settings>`;
|
</task_settings>`;
|
||||||
}
|
}
|
||||||
if (t.confirm === undefined) {
|
if (t.confirm === undefined) t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。';
|
||||||
t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。';
|
if (t.bottom === undefined) t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照<meta_protocol>内要求,进行<thinking>和<msg>互动,开始内省:`;
|
||||||
}
|
if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL;
|
||||||
if (t.bottom === undefined) {
|
if (t.imgGuideline === undefined) t.imgGuideline = IMG_GUIDELINE;
|
||||||
t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照<meta_protocol>内要求,进行<thinking>和<msg>互动,开始内省:`;
|
|
||||||
}
|
|
||||||
if (t.metaProtocol === undefined) {
|
|
||||||
t.metaProtocol = DEFAULT_META_PROTOCOL;
|
|
||||||
}
|
|
||||||
if (t.imgGuideline === undefined) {
|
|
||||||
t.imgGuideline = IMG_GUIDELINE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 工具函数 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function b64UrlEncode(str) {
|
function b64UrlEncode(str) {
|
||||||
const utf8 = new TextEncoder().encode(String(str));
|
const utf8 = new TextEncoder().encode(String(str));
|
||||||
@@ -301,10 +364,7 @@ function getAvatarUrls() {
|
|||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
let user = pickSrc([
|
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 : '');
|
||||||
'#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);
|
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
|
||||||
if (m) user = `User Avatars/${decodeURIComponent(m[1])}`;
|
if (m) user = `User Avatars/${decodeURIComponent(m[1])}`;
|
||||||
const ctx = getContext?.() || {};
|
const ctx = getContext?.() || {};
|
||||||
@@ -336,7 +396,9 @@ async function getUserAndCharNames() {
|
|||||||
return { userName, charName };
|
return { userName, charName };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 存储管理 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 存储管理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function getFWStore(chatId = getCurrentChatIdSafe()) {
|
function getFWStore(chatId = getCurrentChatIdSafe()) {
|
||||||
if (!chatId) return null;
|
if (!chatId) return null;
|
||||||
@@ -371,7 +433,9 @@ function saveFWStore() {
|
|||||||
saveMetadataDebounced?.();
|
saveMetadataDebounced?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== iframe 通讯 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// iframe 通讯
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function postToFrame(payload) {
|
function postToFrame(payload) {
|
||||||
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
|
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) {
|
function handleFrameMessage(event) {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.source !== 'LittleWhiteBox-FourthWall') return;
|
if (!data || data.source !== 'LittleWhiteBox-FourthWall') return;
|
||||||
@@ -529,10 +646,20 @@ function handleFrameMessage(event) {
|
|||||||
case 'CLOSE_OVERLAY':
|
case 'CLOSE_OVERLAY':
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
break;
|
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) {
|
async function buildPrompt(userInput, history, settings, imgSettings, voiceSettings, isCommentary = false) {
|
||||||
const { userName, charName } = await getUserAndCharNames();
|
const { userName, charName } = await getUserAndCharNames();
|
||||||
@@ -579,10 +706,7 @@ async function buildPrompt(userInput, history, settings, imgSettings, voiceSetti
|
|||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
const msg1 = String(T.topuser || '')
|
const msg1 = String(T.topuser || '').replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName);
|
||||||
.replace(/{{USER_NAME}}/g, userName)
|
|
||||||
.replace(/{{CHAR_NAME}}/g, charName);
|
|
||||||
|
|
||||||
const msg2 = String(T.confirm || '好的,我已阅读设置要求,准备查看历史并进入角色。');
|
const msg2 = String(T.confirm || '好的,我已阅读设置要求,准备查看历史并进入角色。');
|
||||||
|
|
||||||
let metaProtocol = (isCommentary ? COMMENTARY_PROTOCOL : 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);
|
||||||
@@ -604,7 +728,9 @@ ${metaProtocol}`.replace(/\|/g, '|').trim();
|
|||||||
return { msg1, msg2, msg3, msg4 };
|
return { msg1, msg2, msg3, msg4 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 生成处理 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 生成处理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async function handleSendMessage(data) {
|
async function handleSendMessage(data) {
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
@@ -616,25 +742,15 @@ async function handleSendMessage(data) {
|
|||||||
saveFWStore();
|
saveFWStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { msg1, msg2, msg3, msg4 } = await buildPrompt(
|
const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings);
|
||||||
data.userInput,
|
|
||||||
data.history,
|
|
||||||
data.settings,
|
|
||||||
data.imgSettings,
|
|
||||||
data.voiceSettings
|
|
||||||
);
|
|
||||||
|
|
||||||
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
||||||
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
|
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
|
||||||
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
|
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeSlashCommand(cmd);
|
await executeSlashCommand(cmd);
|
||||||
if (data.settings.stream) {
|
if (data.settings.stream) startStreamingPoll();
|
||||||
startStreamingPoll();
|
else startNonstreamAwait();
|
||||||
} else {
|
|
||||||
startNonstreamAwait();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
stopStreamingPoll();
|
stopStreamingPoll();
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
@@ -652,25 +768,15 @@ async function handleRegenerate(data) {
|
|||||||
saveFWStore();
|
saveFWStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { msg1, msg2, msg3, msg4 } = await buildPrompt(
|
const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings);
|
||||||
data.userInput,
|
|
||||||
data.history,
|
|
||||||
data.settings,
|
|
||||||
data.imgSettings,
|
|
||||||
data.voiceSettings
|
|
||||||
);
|
|
||||||
|
|
||||||
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
||||||
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
|
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
|
||||||
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
|
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await executeSlashCommand(cmd);
|
await executeSlashCommand(cmd);
|
||||||
if (data.settings.stream) {
|
if (data.settings.stream) startStreamingPoll();
|
||||||
startStreamingPoll();
|
else startNonstreamAwait();
|
||||||
} else {
|
|
||||||
startNonstreamAwait();
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
stopStreamingPoll();
|
stopStreamingPoll();
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
@@ -687,16 +793,10 @@ function startStreamingPoll() {
|
|||||||
const raw = gen.getLastGeneration(STREAM_SESSION_ID) || '...';
|
const raw = gen.getLastGeneration(STREAM_SESSION_ID) || '...';
|
||||||
const thinking = extractThinkingPartial(raw);
|
const thinking = extractThinkingPartial(raw);
|
||||||
const msg = extractMsg(raw) || extractMsgPartial(raw);
|
const msg = extractMsg(raw) || extractMsgPartial(raw);
|
||||||
postToFrame({
|
postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined });
|
||||||
type: 'STREAM_UPDATE',
|
|
||||||
text: msg || '...',
|
|
||||||
thinking: thinking || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
const st = gen.getStatus?.(STREAM_SESSION_ID);
|
const st = gen.getStatus?.(STREAM_SESSION_ID);
|
||||||
if (st && st.isStreaming === false) {
|
if (st && st.isStreaming === false) finalizeGeneration();
|
||||||
finalizeGeneration();
|
|
||||||
}
|
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,9 +805,7 @@ function startNonstreamAwait() {
|
|||||||
streamTimerId = setInterval(() => {
|
streamTimerId = setInterval(() => {
|
||||||
const gen = window.xiaobaixStreamingGeneration;
|
const gen = window.xiaobaixStreamingGeneration;
|
||||||
const st = gen?.getStatus?.(STREAM_SESSION_ID);
|
const st = gen?.getStatus?.(STREAM_SESSION_ID);
|
||||||
if (st && st.isStreaming === false) {
|
if (st && st.isStreaming === false) finalizeGeneration();
|
||||||
finalizeGeneration();
|
|
||||||
}
|
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,12 +827,7 @@ function finalizeGeneration() {
|
|||||||
|
|
||||||
const session = getActiveSession();
|
const session = getActiveSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
session.history.push({
|
session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() });
|
||||||
role: 'ai',
|
|
||||||
content: finalText,
|
|
||||||
thinking: thinkingText || undefined,
|
|
||||||
ts: Date.now()
|
|
||||||
});
|
|
||||||
saveFWStore();
|
saveFWStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +842,9 @@ function cancelGeneration() {
|
|||||||
postToFrame({ type: 'GENERATION_CANCELLED' });
|
postToFrame({ type: 'GENERATION_CANCELLED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 实时吐槽 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 实时吐槽
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function shouldTriggerCommentary() {
|
function shouldTriggerCommentary() {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
@@ -766,25 +861,15 @@ async function buildCommentaryPrompt(targetText, type) {
|
|||||||
const session = getActiveSession();
|
const session = getActiveSession();
|
||||||
if (!store || !session) return null;
|
if (!store || !session) return null;
|
||||||
|
|
||||||
const { msg1, msg2, msg3 } = await buildPrompt(
|
const { msg1, msg2, msg3 } = await buildPrompt('', session.history || [], store.settings || {}, settings.fourthWallImage || {}, settings.fourthWallVoice || {}, true);
|
||||||
'',
|
|
||||||
session.history || [],
|
|
||||||
store.settings || {},
|
|
||||||
settings.fourthWallImage || {},
|
|
||||||
settings.fourthWallVoice || {},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
let msg4;
|
let msg4;
|
||||||
if (type === 'ai_message') {
|
if (type === 'ai_message') {
|
||||||
msg4 = `现在<chat_history>剧本还在继续中,我刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。
|
msg4 = `现在<chat_history>剧本还在继续中,我刚才说完最后一轮rp,忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
|
||||||
我将直接输出<msg>内容</msg>:`;
|
|
||||||
} else if (type === 'edit_own') {
|
} else if (type === 'edit_own') {
|
||||||
msg4 = `现在<chat_history>剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}」
|
msg4 = `现在<chat_history>剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}」必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
|
||||||
必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
|
|
||||||
} else if (type === 'edit_ai') {
|
} else if (type === 'edit_ai') {
|
||||||
msg4 = `现在<chat_history>剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}」
|
msg4 = `现在<chat_history>剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}」必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:。`;
|
||||||
必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:。`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { msg1, msg2, msg3, msg4 };
|
return { msg1, msg2, msg3, msg4 };
|
||||||
@@ -794,7 +879,6 @@ async function generateCommentary(targetText, type) {
|
|||||||
const built = await buildCommentaryPrompt(targetText, type);
|
const built = await buildCommentaryPrompt(targetText, type);
|
||||||
if (!built) return null;
|
if (!built) return null;
|
||||||
const { msg1, msg2, msg3, msg4 } = built;
|
const { msg1, msg2, msg3, msg4 } = built;
|
||||||
|
|
||||||
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -874,16 +958,8 @@ function getFloatBtnPosition() {
|
|||||||
if (!btn) return null;
|
if (!btn) return null;
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
let stored = {};
|
let stored = {};
|
||||||
try {
|
try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch {}
|
||||||
stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {};
|
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' };
|
||||||
} catch {}
|
|
||||||
return {
|
|
||||||
top: rect.top,
|
|
||||||
left: rect.left,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
side: stored.side || 'right'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCommentaryBubble(text) {
|
function showCommentaryBubble(text) {
|
||||||
@@ -895,19 +971,9 @@ function showCommentaryBubble(text) {
|
|||||||
bubble.textContent = text;
|
bubble.textContent = text;
|
||||||
bubble.onclick = hideCommentaryBubble;
|
bubble.onclick = hideCommentaryBubble;
|
||||||
Object.assign(bubble.style, {
|
Object.assign(bubble.style, {
|
||||||
position: 'fixed',
|
position: 'fixed', zIndex: '10000', maxWidth: '200px', padding: '8px 12px',
|
||||||
zIndex: '10000',
|
background: 'rgba(255,255,255,0.95)', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||||
maxWidth: '200px',
|
fontSize: '13px', color: '#333', cursor: 'pointer', opacity: '0', transform: 'scale(0.8)', transition: 'opacity 0.3s, transform 0.3s'
|
||||||
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);
|
document.body.appendChild(bubble);
|
||||||
commentaryBubbleEl = bubble;
|
commentaryBubbleEl = bubble;
|
||||||
@@ -930,10 +996,7 @@ function showCommentaryBubble(text) {
|
|||||||
bubble.style.right = '';
|
bubble.style.right = '';
|
||||||
bubble.style.borderBottomLeftRadius = '4px';
|
bubble.style.borderBottomLeftRadius = '4px';
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => { bubble.style.opacity = '1'; bubble.style.transform = 'scale(1)'; });
|
||||||
bubble.style.opacity = '1';
|
|
||||||
bubble.style.transform = 'scale(1)';
|
|
||||||
});
|
|
||||||
const len = (text || '').length;
|
const len = (text || '').length;
|
||||||
const duration = Math.min(2000 + Math.ceil(len / 5) * 1000, 8000);
|
const duration = Math.min(2000 + Math.ceil(len / 5) * 1000, 8000);
|
||||||
commentaryBubbleTimer = setTimeout(hideCommentaryBubble, duration);
|
commentaryBubbleTimer = setTimeout(hideCommentaryBubble, duration);
|
||||||
@@ -941,17 +1004,11 @@ function showCommentaryBubble(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hideCommentaryBubble() {
|
function hideCommentaryBubble() {
|
||||||
if (commentaryBubbleTimer) {
|
if (commentaryBubbleTimer) { clearTimeout(commentaryBubbleTimer); commentaryBubbleTimer = null; }
|
||||||
clearTimeout(commentaryBubbleTimer);
|
|
||||||
commentaryBubbleTimer = null;
|
|
||||||
}
|
|
||||||
if (commentaryBubbleEl) {
|
if (commentaryBubbleEl) {
|
||||||
commentaryBubbleEl.style.opacity = '0';
|
commentaryBubbleEl.style.opacity = '0';
|
||||||
commentaryBubbleEl.style.transform = 'scale(0.8)';
|
commentaryBubbleEl.style.transform = 'scale(0.8)';
|
||||||
setTimeout(() => {
|
setTimeout(() => { commentaryBubbleEl?.remove(); commentaryBubbleEl = null; }, 300);
|
||||||
commentaryBubbleEl?.remove();
|
|
||||||
commentaryBubbleEl = null;
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,7 +1024,9 @@ function cleanupCommentary() {
|
|||||||
lastCommentaryTime = 0;
|
lastCommentaryTime = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== Overlay 管理 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Overlay 管理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function createOverlay() {
|
function createOverlay() {
|
||||||
if (overlayCreated) return;
|
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 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 = $(`
|
const $overlay = $(`
|
||||||
<div id="xiaobaix-fourth-wall-overlay" style="
|
<div id="xiaobaix-fourth-wall-overlay" style="position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;z-index:99999!important;display:none;overflow:hidden!important;background:#000!important;">
|
||||||
position: fixed !important; inset: 0 !important;
|
<div class="fw-backdrop" style="position:absolute!important;inset:0!important;background:rgba(0,0,0,.55)!important;backdrop-filter:blur(4px)!important;"></div>
|
||||||
width: 100vw !important; height: 100vh !important; height: 100dvh !important;
|
<div class="fw-frame-wrap" style="position:absolute!important;inset:${frameInset}!important;z-index:1!important;${framePadding}">
|
||||||
z-index: 99999 !important; display: none; overflow: hidden !important;
|
<iframe id="xiaobaix-fourth-wall-iframe" class="xiaobaix-iframe" src="${iframePath}" style="width:100%!important;height:100%!important;border:none!important;border-radius:${iframeRadius}!important;box-shadow:0 0 30px rgba(0,0,0,.4)!important;background:#1a1a2e!important;"></iframe>
|
||||||
background: #000 !important;
|
|
||||||
">
|
|
||||||
<div class="fw-backdrop" style="
|
|
||||||
position: absolute !important; inset: 0 !important;
|
|
||||||
background: rgba(0,0,0,.55) !important;
|
|
||||||
backdrop-filter: blur(4px) !important;
|
|
||||||
"></div>
|
|
||||||
<div class="fw-frame-wrap" style="
|
|
||||||
position: absolute !important; inset: ${frameInset} !important; z-index: 1 !important; ${framePadding}
|
|
||||||
">
|
|
||||||
<iframe id="xiaobaix-fourth-wall-iframe" class="xiaobaix-iframe"
|
|
||||||
src="${iframePath}"
|
|
||||||
style="width:100% !important; height:100% !important; border:none !important;
|
|
||||||
border-radius:${iframeRadius} !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
|
|
||||||
background:#1a1a2e !important;">
|
|
||||||
</iframe>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
@@ -1035,9 +1078,7 @@ function showOverlay() {
|
|||||||
|
|
||||||
function hideOverlay() {
|
function hideOverlay() {
|
||||||
$('#xiaobaix-fourth-wall-overlay').hide();
|
$('#xiaobaix-fourth-wall-overlay').hide();
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
|
||||||
document.exitFullscreen().catch(() => {});
|
|
||||||
}
|
|
||||||
isFullscreen = false;
|
isFullscreen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,7 +1099,9 @@ function toggleFullscreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 悬浮按钮 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 悬浮按钮
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function createFloatingButton() {
|
function createFloatingButton() {
|
||||||
if (document.getElementById('xiaobaix-fw-float-btn')) return;
|
if (document.getElementById('xiaobaix-fw-float-btn')) return;
|
||||||
@@ -1068,12 +1111,8 @@ function createFloatingButton() {
|
|||||||
const margin = 8;
|
const margin = 8;
|
||||||
|
|
||||||
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
|
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
|
||||||
const readPos = () => {
|
const readPos = () => { try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; } };
|
||||||
try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; }
|
const writePos = (pos) => { try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {} };
|
||||||
};
|
|
||||||
const writePos = (pos) => {
|
|
||||||
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {}
|
|
||||||
};
|
|
||||||
const calcDockLeft = (side, w) => (side === 'left' ? -Math.round(w / 2) : (window.innerWidth - Math.round(w / 2)));
|
const calcDockLeft = (side, w) => (side === 'left' ? -Math.round(w / 2) : (window.innerWidth - Math.round(w / 2)));
|
||||||
const applyDocked = (side, topRatio) => {
|
const applyDocked = (side, topRatio) => {
|
||||||
const btn = document.getElementById('xiaobaix-fw-float-btn');
|
const btn = document.getElementById('xiaobaix-fw-float-btn');
|
||||||
@@ -1087,27 +1126,7 @@ function createFloatingButton() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const $btn = $(`
|
const $btn = $(`
|
||||||
<button id="xiaobaix-fw-float-btn" title="皮下交流" style="
|
<button id="xiaobaix-fw-float-btn" title="皮下交流" style="position:fixed!important;left:0px!important;top:0px!important;z-index:9999!important;width:${size}px!important;height:${size}px!important;border-radius:50%!important;border:none!important;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)!important;color:#fff!important;font-size:${Math.round(size * 0.45)}px!important;cursor:pointer!important;box-shadow:0 4px 15px rgba(102,126,234,0.4)!important;display:flex!important;align-items:center!important;justify-content:center!important;transition:left 0.2s,top 0.2s,transform 0.2s,box-shadow 0.2s!important;touch-action:none!important;user-select:none!important;">
|
||||||
position: fixed !important;
|
|
||||||
left: 0px !important;
|
|
||||||
top: 0px !important;
|
|
||||||
z-index: 9999 !important;
|
|
||||||
width: ${size}px !important;
|
|
||||||
height: ${size}px !important;
|
|
||||||
border-radius: 50% !important;
|
|
||||||
border: none !important;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
font-size: ${Math.round(size * 0.45)}px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4) !important;
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
transition: left 0.2s, top 0.2s, transform 0.2s, box-shadow 0.2s !important;
|
|
||||||
touch-action: none !important;
|
|
||||||
user-select: none !important;
|
|
||||||
">
|
|
||||||
<i class="fa-solid fa-comments"></i>
|
<i class="fa-solid fa-comments"></i>
|
||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
@@ -1118,19 +1137,8 @@ function createFloatingButton() {
|
|||||||
showOverlay();
|
showOverlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
$btn.on('mouseenter', function() {
|
$btn.on('mouseenter', function() { $(this).css({ 'transform': 'scale(1.08)', 'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)' }); });
|
||||||
$(this).css({
|
$btn.on('mouseleave', function() { $(this).css({ 'transform': 'none', 'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)' }); });
|
||||||
'transform': 'scale(1.08)',
|
|
||||||
'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$btn.on('mouseleave', function() {
|
|
||||||
$(this).css({
|
|
||||||
'transform': 'none',
|
|
||||||
'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild($btn[0]);
|
document.body.appendChild($btn[0]);
|
||||||
|
|
||||||
@@ -1138,11 +1146,7 @@ function createFloatingButton() {
|
|||||||
applyDocked(initial?.side || 'right', Number.isFinite(initial?.topRatio) ? initial.topRatio : 0.5);
|
applyDocked(initial?.side || 'right', Number.isFinite(initial?.topRatio) ? initial.topRatio : 0.5);
|
||||||
|
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
let startX = 0;
|
let startX = 0, startY = 0, startLeft = 0, startTop = 0, pointerId = null;
|
||||||
let startY = 0;
|
|
||||||
let startLeft = 0;
|
|
||||||
let startTop = 0;
|
|
||||||
let pointerId = null;
|
|
||||||
|
|
||||||
const onPointerDown = (e) => {
|
const onPointerDown = (e) => {
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
@@ -1150,10 +1154,7 @@ function createFloatingButton() {
|
|||||||
pointerId = e.pointerId;
|
pointerId = e.pointerId;
|
||||||
try { btn.setPointerCapture(pointerId); } catch {}
|
try { btn.setPointerCapture(pointerId); } catch {}
|
||||||
const rect = btn.getBoundingClientRect();
|
const rect = btn.getBoundingClientRect();
|
||||||
startX = e.clientX;
|
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
|
||||||
startY = e.clientY;
|
|
||||||
startLeft = rect.left;
|
|
||||||
startTop = rect.top;
|
|
||||||
dragging = false;
|
dragging = false;
|
||||||
btn.style.transition = 'none';
|
btn.style.transition = 'none';
|
||||||
};
|
};
|
||||||
@@ -1216,7 +1217,9 @@ function removeFloatingButton() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 初始化和清理 ==================
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 初始化和清理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function initFourthWall() {
|
function initFourthWall() {
|
||||||
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {}
|
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {}
|
||||||
@@ -1225,14 +1228,13 @@ function initFourthWall() {
|
|||||||
|
|
||||||
createFloatingButton();
|
createFloatingButton();
|
||||||
initCommentary();
|
initCommentary();
|
||||||
|
clearExpiredFWImageCache();
|
||||||
|
|
||||||
events.on(event_types.CHAT_CHANGED, () => {
|
events.on(event_types.CHAT_CHANGED, () => {
|
||||||
cancelGeneration();
|
cancelGeneration();
|
||||||
currentLoadedChatId = null;
|
currentLoadedChatId = null;
|
||||||
pendingFrameMessages = [];
|
pendingFrameMessages = [];
|
||||||
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) {
|
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) hideOverlay();
|
||||||
hideOverlay();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const BLOB_CACHE_LIMIT = 32;
|
|||||||
let lastApplyTs = 0;
|
let lastApplyTs = 0;
|
||||||
let pendingHeight = null;
|
let pendingHeight = null;
|
||||||
let pendingRec = null;
|
let pendingRec = null;
|
||||||
|
let hideStyleInjected = false;
|
||||||
|
|
||||||
CacheRegistry.register(MODULE_ID, {
|
CacheRegistry.register(MODULE_ID, {
|
||||||
name: 'Blob URL 缓存',
|
name: 'Blob URL 缓存',
|
||||||
@@ -40,6 +41,29 @@ function getSettings() {
|
|||||||
return extension_settings[EXT_ID] || {};
|
return extension_settings[EXT_ID] || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureHideCodeStyle(enable) {
|
||||||
|
const id = 'xiaobaix-hide-code';
|
||||||
|
const old = document.getElementById(id);
|
||||||
|
if (!enable) {
|
||||||
|
old?.remove();
|
||||||
|
hideStyleInjected = false;
|
||||||
|
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);
|
||||||
|
hideStyleInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveClass(enable) {
|
||||||
|
document.body.classList.toggle('xiaobaix-active', !!enable);
|
||||||
|
}
|
||||||
|
|
||||||
function djb2(str) {
|
function djb2(str) {
|
||||||
let h = 5381;
|
let h = 5381;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
@@ -674,7 +698,16 @@ function shrinkRenderedWindowFull() {
|
|||||||
let messageListenerBound = false;
|
let messageListenerBound = false;
|
||||||
|
|
||||||
export function initRenderer() {
|
export function initRenderer() {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {}
|
try { xbLog.info(MODULE_ID, 'initRenderer'); } catch {}
|
||||||
|
|
||||||
|
if (settings.renderEnabled !== false) {
|
||||||
|
ensureHideCodeStyle(true);
|
||||||
|
setActiveClass(true);
|
||||||
|
}
|
||||||
|
|
||||||
events.on(event_types.GENERATION_STARTED, () => {
|
events.on(event_types.GENERATION_STARTED, () => {
|
||||||
isGenerating = true;
|
isGenerating = true;
|
||||||
});
|
});
|
||||||
@@ -770,6 +803,20 @@ export function cleanupRenderer() {
|
|||||||
window.removeEventListener('message', handleIframeMessage);
|
window.removeEventListener('message', handleIframeMessage);
|
||||||
messageListenerBound = false;
|
messageListenerBound = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureHideCodeStyle(false);
|
||||||
|
setActiveClass(false);
|
||||||
|
|
||||||
|
document.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;
|
||||||
|
pre.style.display = '';
|
||||||
|
delete pre.dataset.xiaobaixBound;
|
||||||
|
});
|
||||||
|
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
isGenerating = false;
|
isGenerating = false;
|
||||||
pendingHeight = null;
|
pendingHeight = null;
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ function bindMessageEvents() {
|
|||||||
|
|
||||||
messageEvents.on(event_types.MESSAGE_SENT, () => {});
|
messageEvents.on(event_types.MESSAGE_SENT, () => {});
|
||||||
messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI);
|
messageEvents.on(event_types.MESSAGE_RECEIVED, refreshOnAI);
|
||||||
messageEvents.on(event_types.MESSAGE_DELETED, () => {});
|
messageEvents.on(event_types.MESSAGE_DELETED, refreshOnAI);
|
||||||
messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI);
|
messageEvents.on(event_types.MESSAGE_UPDATED, refreshOnAI);
|
||||||
messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI);
|
messageEvents.on(event_types.MESSAGE_SWIPED, refreshOnAI);
|
||||||
if (event_types.GENERATION_STARTED) {
|
if (event_types.GENERATION_STARTED) {
|
||||||
@@ -267,14 +267,20 @@ function showSingleModeMessages() {
|
|||||||
if ($targetAI?.length) {
|
if ($targetAI?.length) {
|
||||||
$targetAI.show();
|
$targetAI.show();
|
||||||
|
|
||||||
const $prevUser = $targetAI.prevAll('.mes[is_user="true"]').first();
|
const $prevMessage = $targetAI.prevAll('.mes').first();
|
||||||
if ($prevUser.length) {
|
if ($prevMessage.length) {
|
||||||
$prevUser.show();
|
$prevMessage.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetAI.nextAll('.mes').show();
|
$targetAI.nextAll('.mes').show();
|
||||||
|
|
||||||
addNavigationToLastTwoMessages();
|
addNavigationToLastTwoMessages();
|
||||||
|
} else {
|
||||||
|
const $lastMessages = $messages.slice(-2);
|
||||||
|
if ($lastMessages.length) {
|
||||||
|
$lastMessages.show();
|
||||||
|
addNavigationToLastTwoMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,15 +377,22 @@ function updateSwipesCounter($targetMes) {
|
|||||||
}
|
}
|
||||||
$swipesCounter.html('1​/​1');
|
$swipesCounter.html('1​/​1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const chatContainer = document.getElementById('chat');
|
||||||
|
if (chatContainer) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function toggleDisplayMode() {
|
function toggleDisplayMode() {
|
||||||
|
|
||||||
if (!state.isActive) return;
|
if (!state.isActive) return;
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
settings.showAllMessages = !settings.showAllMessages;
|
settings.showAllMessages = !settings.showAllMessages;
|
||||||
applyModeClasses();
|
applyModeClasses();
|
||||||
updateMessageDisplay();
|
updateMessageDisplay();
|
||||||
|
saveSettingsDebounced();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
317
modules/novel-draw/TAG编写指南.md
Normal file
317
modules/novel-draw/TAG编写指南.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# NOVEL 图像生成 Tag 编写指南(LLM 专用)
|
||||||
|
|
||||||
|
## 一、基础语法规则
|
||||||
|
|
||||||
|
### 1.1 格式规范
|
||||||
|
- Tag 之间使用 **英文逗号 + 空格** 分隔
|
||||||
|
- 示例:`1girl, flower field, sunset`
|
||||||
|
- 所有 Tag 使用英文
|
||||||
|
|
||||||
|
### 1.2 Tag 顺序原则
|
||||||
|
**越靠前的 Tag 影响力越大**,编写时应按以下优先级排列:
|
||||||
|
1. 核心主体(角色数量/性别)
|
||||||
|
2. 整体风格/艺术家
|
||||||
|
3. 品质 Tag
|
||||||
|
4. 外观特征(发型、眼睛、皮肤等)
|
||||||
|
5. 服装细节
|
||||||
|
6. 构图/视角
|
||||||
|
7. 场景/背景
|
||||||
|
8. 氛围/光照/色彩
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心 Tag 类别速查
|
||||||
|
|
||||||
|
### 2.1 主体定义
|
||||||
|
|
||||||
|
| 场景 | 推荐 Tag |
|
||||||
|
|------|----------|
|
||||||
|
| 单个女性 | `1girl, solo` |
|
||||||
|
| 单个男性 | `1boy, solo` |
|
||||||
|
| 多个女性 | `2girls` / `3girls` / `multiple girls` |
|
||||||
|
| 多个男性 | `2boys` / `multiple boys` |
|
||||||
|
| 无人物 | `no humans` |
|
||||||
|
| 混合 | `1boy, 1girl` |
|
||||||
|
|
||||||
|
> `solo` 可防止背景出现额外人物
|
||||||
|
|
||||||
|
### 2.2 头发描述
|
||||||
|
|
||||||
|
**长度:**
|
||||||
|
- `very short hair` / `short hair` / `medium hair` / `long hair` / `very long hair` / `absurdly long hair`
|
||||||
|
|
||||||
|
**发型:**
|
||||||
|
- `bob cut`(波波头)
|
||||||
|
- `ponytail` / `high ponytail` / `low ponytail`(马尾)
|
||||||
|
- `twintails`(双马尾)
|
||||||
|
- `bangs` / `blunt bangs` / `side bangs`(刘海)
|
||||||
|
- `braid` / `twin braids`(辫子)
|
||||||
|
- `curly hair`(卷发)
|
||||||
|
- `messy hair`(凌乱)
|
||||||
|
- `ahoge`(呆毛)
|
||||||
|
|
||||||
|
**颜色:**
|
||||||
|
- 基础:`black hair`, `blonde hair`, `brown hair`, `red hair`, `blue hair`, `pink hair`, `white hair`, `silver hair`, `purple hair`, `green hair`
|
||||||
|
- 特殊:`multicolored hair`, `gradient hair`, `streaked hair`
|
||||||
|
|
||||||
|
### 2.3 眼睛描述
|
||||||
|
|
||||||
|
**颜色:**
|
||||||
|
`blue eyes`, `red eyes`, `green eyes`, `brown eyes`, `purple eyes`, `yellow eyes`, `golden eyes`, `heterochromia`(异色瞳)
|
||||||
|
|
||||||
|
**特征:**
|
||||||
|
- `slit pupils`(竖瞳/猫眼)
|
||||||
|
- `glowing eyes`(发光)
|
||||||
|
- `closed eyes`(闭眼)
|
||||||
|
- `half-closed eyes`(半闭眼)
|
||||||
|
|
||||||
|
### 2.4 皮肤描述
|
||||||
|
|
||||||
|
**肤色:**
|
||||||
|
- `pale skin`(白皙)
|
||||||
|
- `fair skin`(浅肤色)
|
||||||
|
- `tan` / `tanned`(小麦色)
|
||||||
|
- `dark skin`(深色)
|
||||||
|
- `colored skin`(幻想色,需配合具体颜色如 `blue skin`)
|
||||||
|
|
||||||
|
**细节:**
|
||||||
|
`freckles`(雀斑), `mole`(痣), `mole under eye`(眼下痣), `makeup`(化妆)
|
||||||
|
|
||||||
|
### 2.5 身体特征
|
||||||
|
|
||||||
|
**体型:**
|
||||||
|
`skinny`, `slim`, `curvy`, `muscular`, `muscular female`, `petite`, `tall`, `short`
|
||||||
|
|
||||||
|
**胸部(女性):**
|
||||||
|
`flat chest`, `small breasts`, `medium breasts`, `large breasts`, `huge breasts`
|
||||||
|
|
||||||
|
### 2.6 服装
|
||||||
|
|
||||||
|
**原则:需要具体描述每个组成部分**
|
||||||
|
|
||||||
|
**头部:**
|
||||||
|
`hat`, `witch hat`, `beret`, `crown`, `hair ribbon`, `hairband`, `glasses`
|
||||||
|
|
||||||
|
**上身:**
|
||||||
|
`shirt`, `dress shirt`, `blouse`, `sweater`, `hoodie`, `jacket`, `coat`, `vest`, `dress`, `kimono`
|
||||||
|
|
||||||
|
**下身:**
|
||||||
|
`skirt`, `long skirt`, `miniskirt`, `pants`, `shorts`, `jeans`
|
||||||
|
|
||||||
|
**足部:**
|
||||||
|
`boots`, `high heels`, `sneakers`, `barefoot`, `thighhighs`, `pantyhose`, `socks`
|
||||||
|
|
||||||
|
**配饰:**
|
||||||
|
`scarf`, `necklace`, `earrings`, `gloves`, `bag`
|
||||||
|
|
||||||
|
**颜色/材质前缀:**
|
||||||
|
可在服装前加颜色或材质,如 `white dress`, `leather jacket`, `silk ribbon`
|
||||||
|
|
||||||
|
### 2.7 艺术风格与媒介
|
||||||
|
|
||||||
|
**数字媒介:**
|
||||||
|
- `anime screencap`(动画截图风格)
|
||||||
|
- `game cg`(游戏CG)
|
||||||
|
- `pixel art`(像素艺术)
|
||||||
|
- `3d`(3D渲染)
|
||||||
|
- `official art`(官方设定风格)
|
||||||
|
|
||||||
|
**传统艺术:**
|
||||||
|
- `realistic` / `photorealistic`(写实/照片级写实)
|
||||||
|
- `impressionism`(印象派)
|
||||||
|
- `art nouveau`(新艺术运动)
|
||||||
|
- `ukiyo-e`(浮世绘)
|
||||||
|
- `sketch`(素描)
|
||||||
|
- `lineart`(线稿)
|
||||||
|
- `watercolor`(水彩)
|
||||||
|
|
||||||
|
**年代风格:**
|
||||||
|
- `retro artstyle`(复古)
|
||||||
|
- `year 2014`(特定年份风格)
|
||||||
|
|
||||||
|
### 2.8 品质 Tag
|
||||||
|
|
||||||
|
**常用组合:**
|
||||||
|
```
|
||||||
|
masterpiece, best quality, very aesthetic, absurdres, ultra detailed
|
||||||
|
```
|
||||||
|
|
||||||
|
| Tag | 作用 |
|
||||||
|
|-----|------|
|
||||||
|
| `masterpiece` | 杰作级质量 |
|
||||||
|
| `best quality` | 最佳质量 |
|
||||||
|
| `high quality` | 高质量 |
|
||||||
|
| `very aesthetic` | 高美感 |
|
||||||
|
| `absurdres` | 超高分辨率 |
|
||||||
|
| `ultra detailed` | 极致细节 |
|
||||||
|
|
||||||
|
### 2.9 构图与取景
|
||||||
|
|
||||||
|
**取景范围:**
|
||||||
|
- `close-up`(特写)
|
||||||
|
- `portrait`(肖像/头肩)
|
||||||
|
- `upper body`(上半身)
|
||||||
|
- `cowboy shot`(到大腿)
|
||||||
|
- `full body`(全身)
|
||||||
|
- `wide shot`(远景)
|
||||||
|
|
||||||
|
**视角:**
|
||||||
|
- `from front`(正面)
|
||||||
|
- `from side`(侧面)
|
||||||
|
- `from behind`(背面)
|
||||||
|
- `from above`(俯视)
|
||||||
|
- `from below`(仰视)
|
||||||
|
- `dutch angle`(倾斜视角)
|
||||||
|
- `profile`(正侧面轮廓)
|
||||||
|
|
||||||
|
**特殊:**
|
||||||
|
- `multiple views`(多视图)
|
||||||
|
- `reference sheet`(角色设定图)
|
||||||
|
|
||||||
|
### 2.10 氛围、光照与色彩
|
||||||
|
|
||||||
|
**光照:**
|
||||||
|
- `cinematic lighting`(电影感光照)
|
||||||
|
- `volumetric lighting`(体积光)
|
||||||
|
- `backlighting`(逆光)
|
||||||
|
- `soft lighting`(柔光)
|
||||||
|
- `dramatic lighting`(戏剧性光照)
|
||||||
|
- `golden hour`(黄金时段光线)
|
||||||
|
- `bloom`(光晕)
|
||||||
|
- `bokeh`(焦外虚化)
|
||||||
|
- `lens flare`(镜头光晕)
|
||||||
|
|
||||||
|
**色彩风格:**
|
||||||
|
- `monochrome`(单色)
|
||||||
|
- `greyscale`(灰度)
|
||||||
|
- `sepia`(棕褐色调)
|
||||||
|
- `limited palette`(有限调色板)
|
||||||
|
- `high contrast`(高对比度)
|
||||||
|
- `flat color`(平涂)
|
||||||
|
- `vibrant colors`(鲜艳色彩)
|
||||||
|
|
||||||
|
**主题色:**
|
||||||
|
`blue theme`, `red theme`, `dark theme`, `warm colors`, `cool colors`
|
||||||
|
|
||||||
|
**氛围:**
|
||||||
|
`mysterious`, `serene`, `melancholic`, `joyful`, `dark`, `ethereal`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、权重控制语法
|
||||||
|
|
||||||
|
### 3.1 增强权重
|
||||||
|
|
||||||
|
**花括号方式:**
|
||||||
|
```
|
||||||
|
{tag} → 约 1.05 倍
|
||||||
|
{{tag}} → 约 1.10 倍
|
||||||
|
{{{tag}}} → 约 1.16 倍
|
||||||
|
```
|
||||||
|
|
||||||
|
**数值化方式(推荐):**
|
||||||
|
```
|
||||||
|
1.2::tag:: → 1.2 倍权重
|
||||||
|
1.5::tag1, tag2:: → 对多个 tag 同时增强
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 削弱权重
|
||||||
|
|
||||||
|
**方括号方式:**
|
||||||
|
```
|
||||||
|
[tag] → 削弱
|
||||||
|
[[tag]] → 更强削弱
|
||||||
|
```
|
||||||
|
|
||||||
|
**数值化方式(推荐):**
|
||||||
|
```
|
||||||
|
0.8::tag:: → 0.8 倍权重
|
||||||
|
0.5::tag:: → 0.5 倍权重
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 语法技巧
|
||||||
|
- `::` 可结束强调区域
|
||||||
|
- `::` 可自动闭合未配对的括号,如 `{{{{{tag ::`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、从文本生成 Tag 的工作流程
|
||||||
|
|
||||||
|
### 步骤 1:识别核心要素
|
||||||
|
从描述中提取:
|
||||||
|
- 人物数量和性别
|
||||||
|
- 整体风格/氛围
|
||||||
|
|
||||||
|
### 步骤 2:提取外观特征
|
||||||
|
按顺序识别:
|
||||||
|
- 发型、发色
|
||||||
|
- 眼睛颜色/特征
|
||||||
|
- 肤色
|
||||||
|
- 体型
|
||||||
|
|
||||||
|
### 步骤 3:识别服装
|
||||||
|
分层描述:
|
||||||
|
- 头饰
|
||||||
|
- 上装
|
||||||
|
- 下装
|
||||||
|
- 鞋袜
|
||||||
|
- 配饰
|
||||||
|
|
||||||
|
### 步骤 4:确定构图
|
||||||
|
- 取景范围
|
||||||
|
- 视角
|
||||||
|
- 特殊构图需求
|
||||||
|
|
||||||
|
### 步骤 5:设定氛围
|
||||||
|
- 光照条件
|
||||||
|
- 色彩倾向
|
||||||
|
- 情感基调
|
||||||
|
|
||||||
|
### 步骤 6:添加品质和风格 Tag
|
||||||
|
- 品质 Tag
|
||||||
|
- 艺术风格(如需要)
|
||||||
|
|
||||||
|
### 步骤 7:组装并调整权重
|
||||||
|
- 按优先级排列
|
||||||
|
- 对重要元素增强权重
|
||||||
|
- 编写负面提示词
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、输出格式模板
|
||||||
|
|
||||||
|
```
|
||||||
|
主体, 品质Tag, 艺术风格, 发型, 发色, 眼睛, 皮肤, 体型, 服装细节, 构图, 场景, 光照, 色彩氛围
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实例演示
|
||||||
|
|
||||||
|
### 输入描述:
|
||||||
|
> "一个有着长长银色头发和红色眼睛的神秘女巫,穿着黑色斗篷和尖顶帽,站在月光下的森林中,整体氛围阴郁而神秘"
|
||||||
|
|
||||||
|
### 输出 Tag:
|
||||||
|
|
||||||
|
```
|
||||||
|
1girl, solo, masterpiece, best quality, very aesthetic, witch, long hair, silver hair, red eyes, pale skin, witch hat, black cloak, black robe, full body, standing, forest, night, moonlight, dark atmosphere, mysterious, cinematic lighting, volumetric lighting, {{{dark theme}}}, high contrast
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、注意事项
|
||||||
|
|
||||||
|
1. **具体优于抽象**:`blue pleated skirt` 优于 `skirt`
|
||||||
|
2. **避免冲突 Tag**:如同时使用 `short hair` 和 `long hair`
|
||||||
|
3. **肤色需明确指定**:不指定时默认倾向白皙
|
||||||
|
4. **服装描述要分层**:每个部件单独描述效果更好
|
||||||
|
5. **NSFW 场景必须明确**:如果场景涉及性内容,必须使用精确的标签。包括:
|
||||||
|
- 身体部位:`penis`, `anus`, `vagina`, `pubic_hair` 等
|
||||||
|
- 性行为方式:`oral`, `fellatio`, `gokkun`, `anal sex`, `vaginal sex` 等
|
||||||
|
- 体位:`missionary`, `doggystyle`, `mating_press`, `deepthroat` 等
|
||||||
|
- 插入场景中:须添加 `cross section`, `X-ray` 展示体内透视效果
|
||||||
|
- 进程阶段:`gently thrusting`, `hard thrusting`, `squirting`, `orgasm` 等
|
||||||
|
- 射精相关:`cum inside`, `cum in uterus`, `stomach bulge`, `female_ejaculation` 等
|
||||||
|
|
||||||
|
**切勿模糊描述**,NovelAI 需要具体的解剖学标签才能正确渲染。
|
||||||
758
modules/novel-draw/floating-panel.js
Normal file
758
modules/novel-draw/floating-panel.js
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
// floating-panel.js
|
||||||
|
// Novel Draw 悬浮面板 - 冷却倒计时优化版(修复版)
|
||||||
|
|
||||||
|
import {
|
||||||
|
openNovelDrawSettings,
|
||||||
|
generateAndInsertImages,
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
isModuleEnabled,
|
||||||
|
findLastAIMessageId,
|
||||||
|
classifyError,
|
||||||
|
ErrorType,
|
||||||
|
} from './novel-draw.js';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 常量
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const FLOAT_POS_KEY = 'xb_novel_float_pos';
|
||||||
|
const AUTO_RESET_DELAY = 8000;
|
||||||
|
|
||||||
|
const FloatState = {
|
||||||
|
IDLE: 'idle',
|
||||||
|
LLM: 'llm',
|
||||||
|
GEN: 'gen',
|
||||||
|
COOLDOWN: 'cooldown',
|
||||||
|
SUCCESS: 'success',
|
||||||
|
PARTIAL: 'partial',
|
||||||
|
ERROR: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 状态
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let floatEl = null;
|
||||||
|
let dragState = null;
|
||||||
|
let currentState = FloatState.IDLE;
|
||||||
|
let currentResult = { success: 0, total: 0, error: null, startTime: 0 };
|
||||||
|
let autoResetTimer = null;
|
||||||
|
|
||||||
|
// 冷却倒计时相关
|
||||||
|
let cooldownTimer = null;
|
||||||
|
let cooldownEndTime = 0;
|
||||||
|
|
||||||
|
// DOM 缓存
|
||||||
|
let $cache = {};
|
||||||
|
|
||||||
|
function cacheDOM() {
|
||||||
|
if (!floatEl) return;
|
||||||
|
$cache = {
|
||||||
|
capsule: floatEl.querySelector('.nd-capsule'),
|
||||||
|
statusIcon: floatEl.querySelector('#nd-status-icon'),
|
||||||
|
statusText: floatEl.querySelector('#nd-status-text'),
|
||||||
|
detailResult: floatEl.querySelector('#nd-detail-result'),
|
||||||
|
detailErrorRow: floatEl.querySelector('#nd-detail-error-row'),
|
||||||
|
detailError: floatEl.querySelector('#nd-detail-error'),
|
||||||
|
detailTime: floatEl.querySelector('#nd-detail-time'),
|
||||||
|
presetSelect: floatEl.querySelector('#nd-preset-select'),
|
||||||
|
autoDot: floatEl.querySelector('#nd-menu-auto-dot'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 样式
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const STYLES = `
|
||||||
|
:root {
|
||||||
|
--nd-w: 74px; --nd-h: 34px;
|
||||||
|
--nd-bg: rgba(28,28,32,0.96);
|
||||||
|
--nd-border: rgba(255,255,255,0.12);
|
||||||
|
--nd-accent: #d4a574;
|
||||||
|
--nd-success: #3ecf8e;
|
||||||
|
--nd-warning: #f0b429;
|
||||||
|
--nd-error: #f87171;
|
||||||
|
--nd-cooldown: #60a5fa;
|
||||||
|
}
|
||||||
|
.nd-float { position: fixed; z-index: 10000; user-select: none; }
|
||||||
|
.nd-capsule {
|
||||||
|
width: var(--nd-w); height: var(--nd-h);
|
||||||
|
background: var(--nd-bg);
|
||||||
|
border: 1px solid var(--nd-border);
|
||||||
|
border-radius: 17px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
touch-action: none; cursor: grab;
|
||||||
|
}
|
||||||
|
.nd-capsule:active { cursor: grabbing; }
|
||||||
|
.nd-float:hover .nd-capsule {
|
||||||
|
border-color: rgba(255,255,255,0.25);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.45);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态边框 */
|
||||||
|
.nd-float.working .nd-capsule { border-color: rgba(240,180,41,0.5); }
|
||||||
|
.nd-float.cooldown .nd-capsule { border-color: rgba(96,165,250,0.6); background: rgba(96,165,250,0.08); }
|
||||||
|
.nd-float.success .nd-capsule { border-color: rgba(62,207,142,0.6); background: rgba(62,207,142,0.08); }
|
||||||
|
.nd-float.partial .nd-capsule { border-color: rgba(240,180,41,0.6); background: rgba(240,180,41,0.08); }
|
||||||
|
.nd-float.error .nd-capsule { border-color: rgba(248,113,113,0.6); background: rgba(248,113,113,0.08); }
|
||||||
|
|
||||||
|
/* 层叠 */
|
||||||
|
.nd-inner { display: grid; width: 100%; height: 100%; grid-template-areas: "s"; pointer-events: none; }
|
||||||
|
.nd-layer { grid-area: s; display: flex; align-items: center; width: 100%; height: 100%; transition: opacity 0.2s, transform 0.2s; pointer-events: auto; }
|
||||||
|
.nd-layer-idle { opacity: 1; transform: translateY(0); }
|
||||||
|
.nd-float.working .nd-layer-idle, .nd-float.cooldown .nd-layer-idle,
|
||||||
|
.nd-float.success .nd-layer-idle, .nd-float.partial .nd-layer-idle,
|
||||||
|
.nd-float.error .nd-layer-idle {
|
||||||
|
opacity: 0; transform: translateY(-100%); pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.nd-btn-draw {
|
||||||
|
flex: 1; height: 100%; border: none; background: transparent;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
position: relative; color: rgba(255,255,255,0.9); transition: background 0.15s;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||||
|
}
|
||||||
|
.nd-btn-draw:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
.nd-btn-draw:active { background: rgba(255,255,255,0.12); }
|
||||||
|
|
||||||
|
.nd-auto-dot {
|
||||||
|
position: absolute; top: 7px; right: 6px; width: 6px; height: 6px;
|
||||||
|
background: var(--nd-success); border-radius: 50%;
|
||||||
|
box-shadow: 0 0 4px rgba(62,207,142,0.6);
|
||||||
|
opacity: 0; transform: scale(0); transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); }
|
||||||
|
|
||||||
|
.nd-sep { width: 1px; height: 14px; background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.nd-btn-menu {
|
||||||
|
width: 28px; height: 100%; border: none; background: transparent;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: rgba(255,255,255,0.4); font-size: 8px; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.nd-btn-menu:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
|
.nd-arrow { transition: transform 0.2s; }
|
||||||
|
.nd-float.expanded .nd-arrow { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* 工作层 */
|
||||||
|
.nd-layer-active {
|
||||||
|
opacity: 0; transform: translateY(100%);
|
||||||
|
justify-content: center; gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600; color: #fff;
|
||||||
|
cursor: pointer; pointer-events: none;
|
||||||
|
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||||
|
}
|
||||||
|
.nd-float.working .nd-layer-active, .nd-float.cooldown .nd-layer-active,
|
||||||
|
.nd-float.success .nd-layer-active, .nd-float.partial .nd-layer-active,
|
||||||
|
.nd-float.error .nd-layer-active {
|
||||||
|
opacity: 1; transform: translateY(0); pointer-events: auto;
|
||||||
|
}
|
||||||
|
.nd-float.cooldown .nd-layer-active { color: var(--nd-cooldown); }
|
||||||
|
.nd-float.success .nd-layer-active { color: var(--nd-success); }
|
||||||
|
.nd-float.partial .nd-layer-active { color: var(--nd-warning); }
|
||||||
|
.nd-float.error .nd-layer-active { color: var(--nd-error); }
|
||||||
|
|
||||||
|
/* 🔧 修复1:旋转动画 */
|
||||||
|
.nd-spin {
|
||||||
|
display: inline-block;
|
||||||
|
animation: nd-spin 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes nd-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* 倒计时数字 - 等宽显示 */
|
||||||
|
.nd-countdown {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情气泡 */
|
||||||
|
.nd-detail {
|
||||||
|
position: absolute; bottom: calc(100% + 8px); left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(4px);
|
||||||
|
background: rgba(20,20,24,0.98);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 8px; padding: 10px 14px;
|
||||||
|
font-size: 11px; color: rgba(255,255,255,0.8);
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
|
opacity: 0; visibility: hidden;
|
||||||
|
transition: all 0.15s ease; z-index: 10;
|
||||||
|
}
|
||||||
|
.nd-detail::after {
|
||||||
|
content: ''; position: absolute; bottom: -5px; left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: rgba(20,20,24,0.98);
|
||||||
|
}
|
||||||
|
.nd-float.show-detail .nd-detail {
|
||||||
|
opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
.nd-detail-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
|
||||||
|
.nd-detail-row + .nd-detail-row {
|
||||||
|
margin-top: 4px; padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.nd-detail-icon { opacity: 0.6; }
|
||||||
|
.nd-detail-label { color: rgba(255,255,255,0.5); }
|
||||||
|
.nd-detail-value { margin-left: auto; font-weight: 600; }
|
||||||
|
.nd-detail-value.success { color: var(--nd-success); }
|
||||||
|
.nd-detail-value.warning { color: var(--nd-warning); }
|
||||||
|
.nd-detail-value.error { color: var(--nd-error); }
|
||||||
|
|
||||||
|
/* 菜单 */
|
||||||
|
.nd-menu {
|
||||||
|
position: absolute; bottom: calc(100% + 8px); right: 0;
|
||||||
|
width: 180px; background: rgba(28,28,32,0.98);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 10px; padding: 6px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
opacity: 0; visibility: hidden;
|
||||||
|
transform: translateY(6px) scale(0.98);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
transition: all 0.15s cubic-bezier(0.34,1.56,0.64,1);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.nd-float.expanded .nd-menu {
|
||||||
|
opacity: 1; visibility: visible; transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
.nd-menu-header {
|
||||||
|
padding: 6px 10px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
.nd-menu-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
cursor: pointer; color: rgba(255,255,255,0.75);
|
||||||
|
font-size: 12px; transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.nd-menu-item:hover { background: rgba(255,255,255,0.08); }
|
||||||
|
.nd-menu-item.active { color: var(--accent); }
|
||||||
|
.nd-item-icon { width: 14px; text-align: center; font-size: 10px; opacity: 0.5; }
|
||||||
|
.nd-menu-item.active .nd-item-icon { opacity: 1; }
|
||||||
|
.nd-menu-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; }
|
||||||
|
.nd-menu-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.2); margin-left: auto; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.nd-menu-dot.active {
|
||||||
|
background: var(--nd-success);
|
||||||
|
box-shadow: 0 0 6px rgba(62,207,142,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预设下拉框 */
|
||||||
|
.nd-preset-row { padding: 4px 10px 8px; }
|
||||||
|
.nd-preset-select {
|
||||||
|
width: 100%; padding: 6px 8px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
font-size: 12px; cursor: pointer; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.nd-preset-select:hover { border-color: rgba(255,255,255,0.25); }
|
||||||
|
.nd-preset-select:focus { border-color: var(--nd-accent); }
|
||||||
|
.nd-preset-select option { background: #1a1a1e; color: #fff; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById('nd-float-styles')) return;
|
||||||
|
const el = document.createElement('style');
|
||||||
|
el.id = 'nd-float-styles';
|
||||||
|
el.textContent = STYLES;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 位置管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function getPosition() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FLOAT_POS_KEY);
|
||||||
|
if (raw) return JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const debug = document.getElementById('xiaobaix-debug-mini');
|
||||||
|
if (debug) {
|
||||||
|
const r = debug.getBoundingClientRect();
|
||||||
|
return { left: r.left, top: r.bottom + 8 };
|
||||||
|
}
|
||||||
|
return { left: window.innerWidth - 110, top: window.innerHeight - 80 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePosition() {
|
||||||
|
if (!floatEl) return;
|
||||||
|
const r = floatEl.getBoundingClientRect();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({
|
||||||
|
left: Math.round(r.left),
|
||||||
|
top: Math.round(r.top)
|
||||||
|
}));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPosition() {
|
||||||
|
if (!floatEl) return;
|
||||||
|
const pos = getPosition();
|
||||||
|
const w = floatEl.offsetWidth || 77;
|
||||||
|
const h = floatEl.offsetHeight || 34;
|
||||||
|
floatEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`;
|
||||||
|
floatEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 冷却倒计时
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function clearCooldownTimer() {
|
||||||
|
if (cooldownTimer) {
|
||||||
|
clearInterval(cooldownTimer);
|
||||||
|
cooldownTimer = null;
|
||||||
|
}
|
||||||
|
cooldownEndTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCooldownTimer(duration) {
|
||||||
|
clearCooldownTimer();
|
||||||
|
|
||||||
|
cooldownEndTime = Date.now() + duration;
|
||||||
|
|
||||||
|
// 立即更新一次
|
||||||
|
updateCooldownDisplay();
|
||||||
|
|
||||||
|
// 🔧 修复3:每50ms更新一次,更流畅,且始终更新显示
|
||||||
|
cooldownTimer = setInterval(() => {
|
||||||
|
updateCooldownDisplay();
|
||||||
|
|
||||||
|
// 倒计时结束后清理定时器(但不切换状态,等 novel-draw.js 来切换)
|
||||||
|
if (cooldownEndTime - Date.now() <= -100) {
|
||||||
|
clearCooldownTimer();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCooldownDisplay() {
|
||||||
|
const { statusIcon, statusText } = $cache;
|
||||||
|
if (!statusIcon || !statusText) return;
|
||||||
|
|
||||||
|
// 🔧 修复2 & 3:显示小数点后一位,最小显示0.0
|
||||||
|
const remaining = Math.max(0, cooldownEndTime - Date.now());
|
||||||
|
const seconds = (remaining / 1000).toFixed(1);
|
||||||
|
|
||||||
|
statusText.textContent = `${seconds}s`;
|
||||||
|
statusText.className = 'nd-countdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 状态管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// 🔧 修复1:spinning 设为 true
|
||||||
|
const STATE_CONFIG = {
|
||||||
|
[FloatState.IDLE]: { cls: '', icon: '', text: '', spinning: false },
|
||||||
|
[FloatState.LLM]: { cls: 'working', icon: '⏳', text: '分析', spinning: true },
|
||||||
|
[FloatState.GEN]: { cls: 'working', icon: '🎨', text: '', spinning: true },
|
||||||
|
[FloatState.COOLDOWN]: { cls: 'cooldown', icon: '⏳', text: '', spinning: true },
|
||||||
|
[FloatState.SUCCESS]: { cls: 'success', icon: '✓', text: '', spinning: false },
|
||||||
|
[FloatState.PARTIAL]: { cls: 'partial', icon: '⚠', text: '', spinning: false },
|
||||||
|
[FloatState.ERROR]: { cls: 'error', icon: '✗', text: '', spinning: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setState(state, data = {}) {
|
||||||
|
if (!floatEl) return;
|
||||||
|
|
||||||
|
currentState = state;
|
||||||
|
|
||||||
|
// 清理自动重置定时器
|
||||||
|
if (autoResetTimer) {
|
||||||
|
clearTimeout(autoResetTimer);
|
||||||
|
autoResetTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非冷却状态时清理冷却定时器
|
||||||
|
if (state !== FloatState.COOLDOWN) {
|
||||||
|
clearCooldownTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除所有状态类
|
||||||
|
floatEl.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
|
||||||
|
|
||||||
|
const cfg = STATE_CONFIG[state];
|
||||||
|
if (cfg.cls) floatEl.classList.add(cfg.cls);
|
||||||
|
|
||||||
|
const { statusIcon, statusText } = $cache;
|
||||||
|
if (!statusIcon || !statusText) return;
|
||||||
|
|
||||||
|
// 🔧 修复1:根据 spinning 添加旋转类
|
||||||
|
statusIcon.textContent = cfg.icon;
|
||||||
|
statusIcon.className = cfg.spinning ? 'nd-spin' : '';
|
||||||
|
statusText.className = '';
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case FloatState.IDLE:
|
||||||
|
currentResult = { success: 0, total: 0, error: null, startTime: 0 };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FloatState.LLM:
|
||||||
|
currentResult.startTime = Date.now();
|
||||||
|
statusText.textContent = cfg.text;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FloatState.GEN:
|
||||||
|
statusText.textContent = `${data.current || 0}/${data.total || 0}`;
|
||||||
|
currentResult.total = data.total || 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FloatState.COOLDOWN:
|
||||||
|
// 启动冷却倒计时
|
||||||
|
startCooldownTimer(data.duration);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FloatState.SUCCESS:
|
||||||
|
case FloatState.PARTIAL:
|
||||||
|
statusText.textContent = `${data.success}/${data.total}`;
|
||||||
|
currentResult.success = data.success;
|
||||||
|
currentResult.total = data.total;
|
||||||
|
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FloatState.ERROR:
|
||||||
|
statusText.textContent = data.error?.label || '错误';
|
||||||
|
currentResult.error = data.error;
|
||||||
|
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(current, total) {
|
||||||
|
if (currentState !== FloatState.GEN || !$cache.statusText) return;
|
||||||
|
$cache.statusText.textContent = `${current}/${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDetailPopup() {
|
||||||
|
const { detailResult, detailErrorRow, detailError, detailTime } = $cache;
|
||||||
|
if (!detailResult) return;
|
||||||
|
|
||||||
|
const elapsed = currentResult.startTime
|
||||||
|
? ((Date.now() - currentResult.startTime) / 1000).toFixed(1)
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const isSuccess = currentState === FloatState.SUCCESS;
|
||||||
|
const isPartial = currentState === FloatState.PARTIAL;
|
||||||
|
const isError = currentState === FloatState.ERROR;
|
||||||
|
|
||||||
|
if (isSuccess || isPartial) {
|
||||||
|
detailResult.textContent = `${currentResult.success}/${currentResult.total} 成功`;
|
||||||
|
detailResult.className = `nd-detail-value ${isSuccess ? 'success' : 'warning'}`;
|
||||||
|
detailErrorRow.style.display = isPartial ? 'flex' : 'none';
|
||||||
|
if (isPartial) detailError.textContent = `${currentResult.total - currentResult.success} 张失败`;
|
||||||
|
} else if (isError) {
|
||||||
|
detailResult.textContent = '生成失败';
|
||||||
|
detailResult.className = 'nd-detail-value error';
|
||||||
|
detailErrorRow.style.display = 'flex';
|
||||||
|
detailError.textContent = currentResult.error?.desc || '未知错误';
|
||||||
|
}
|
||||||
|
|
||||||
|
detailTime.textContent = `${elapsed}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 拖拽与点击
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
dragState = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startLeft: floatEl.getBoundingClientRect().left,
|
||||||
|
startTop: floatEl.getBoundingClientRect().top,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
moved: false,
|
||||||
|
originalTarget: e.target
|
||||||
|
};
|
||||||
|
|
||||||
|
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - dragState.startX;
|
||||||
|
const dy = e.clientY - dragState.startY;
|
||||||
|
|
||||||
|
if (!dragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||||
|
dragState.moved = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dragState.moved) {
|
||||||
|
const w = floatEl.offsetWidth || 88;
|
||||||
|
const h = floatEl.offsetHeight || 36;
|
||||||
|
floatEl.style.left = `${Math.max(0, Math.min(dragState.startLeft + dx, window.innerWidth - w))}px`;
|
||||||
|
floatEl.style.top = `${Math.max(0, Math.min(dragState.startTop + dy, window.innerHeight - h))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e) {
|
||||||
|
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||||
|
|
||||||
|
const { moved, originalTarget } = dragState;
|
||||||
|
|
||||||
|
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
||||||
|
dragState = null;
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
savePosition();
|
||||||
|
} else {
|
||||||
|
routeClick(originalTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeClick(target) {
|
||||||
|
if (target.closest('#nd-btn-draw')) {
|
||||||
|
handleDrawClick();
|
||||||
|
} else if (target.closest('#nd-btn-menu')) {
|
||||||
|
floatEl.classList.remove('show-detail');
|
||||||
|
if (!floatEl.classList.contains('expanded')) {
|
||||||
|
refreshPresetSelect();
|
||||||
|
}
|
||||||
|
floatEl.classList.toggle('expanded');
|
||||||
|
} else if (target.closest('#nd-layer-active')) {
|
||||||
|
if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(currentState)) {
|
||||||
|
updateDetailPopup();
|
||||||
|
floatEl.classList.toggle('show-detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 核心操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function handleDrawClick() {
|
||||||
|
if (currentState !== FloatState.IDLE) return;
|
||||||
|
|
||||||
|
const messageId = findLastAIMessageId();
|
||||||
|
if (messageId < 0) {
|
||||||
|
toastr?.warning?.('没有可配图的AI消息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await generateAndInsertImages({
|
||||||
|
messageId,
|
||||||
|
onStateChange: (state, data) => {
|
||||||
|
switch (state) {
|
||||||
|
case 'llm':
|
||||||
|
setState(FloatState.LLM);
|
||||||
|
break;
|
||||||
|
case 'gen':
|
||||||
|
setState(FloatState.GEN, data);
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
setState(FloatState.GEN, data); // 用 GEN 状态显示进度
|
||||||
|
break;
|
||||||
|
case 'cooldown':
|
||||||
|
setState(FloatState.COOLDOWN, data);
|
||||||
|
break;
|
||||||
|
case 'success':
|
||||||
|
setState(data.success === data.total ? FloatState.SUCCESS : FloatState.PARTIAL, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NovelDraw]', e);
|
||||||
|
setState(FloatState.ERROR, { error: classifyError(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 预设管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function buildPresetOptions() {
|
||||||
|
const settings = getSettings();
|
||||||
|
const presets = settings.paramsPresets || [];
|
||||||
|
const currentId = settings.selectedParamsPresetId;
|
||||||
|
|
||||||
|
return presets.map(p =>
|
||||||
|
`<option value="${p.id}"${p.id === currentId ? ' selected' : ''}>${p.name || '未命名'}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPresetSelect() {
|
||||||
|
if (!$cache.presetSelect) return;
|
||||||
|
$cache.presetSelect.innerHTML = buildPresetOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePresetChange(e) {
|
||||||
|
const presetId = e.target.value;
|
||||||
|
if (!presetId) return;
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
settings.selectedParamsPresetId = presetId;
|
||||||
|
saveSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAutoModeUI() {
|
||||||
|
if (!floatEl) return;
|
||||||
|
const isAuto = getSettings().mode === 'auto';
|
||||||
|
floatEl.classList.toggle('auto-on', isAuto);
|
||||||
|
|
||||||
|
const menuDot = floatEl.querySelector('#nd-menu-auto-dot');
|
||||||
|
menuDot?.classList.toggle('active', isAuto);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAutoToggle() {
|
||||||
|
const settings = getSettings();
|
||||||
|
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
|
||||||
|
saveSettings(settings);
|
||||||
|
updateAutoModeUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 创建与销毁
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function createFloatingPanel() {
|
||||||
|
if (floatEl) return;
|
||||||
|
|
||||||
|
injectStyles();
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
const isAuto = settings.mode === 'auto';
|
||||||
|
|
||||||
|
floatEl = document.createElement('div');
|
||||||
|
floatEl.className = `nd-float${isAuto ? ' auto-on' : ''}`;
|
||||||
|
floatEl.id = 'nd-floating-panel';
|
||||||
|
|
||||||
|
floatEl.innerHTML = `
|
||||||
|
<div class="nd-detail">
|
||||||
|
<div class="nd-detail-row">
|
||||||
|
<span class="nd-detail-icon">📊</span>
|
||||||
|
<span class="nd-detail-label">结果</span>
|
||||||
|
<span class="nd-detail-value" id="nd-detail-result">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="nd-detail-row" id="nd-detail-error-row" style="display:none">
|
||||||
|
<span class="nd-detail-icon">💡</span>
|
||||||
|
<span class="nd-detail-label">原因</span>
|
||||||
|
<span class="nd-detail-value error" id="nd-detail-error">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="nd-detail-row">
|
||||||
|
<span class="nd-detail-icon">⏱</span>
|
||||||
|
<span class="nd-detail-label">耗时</span>
|
||||||
|
<span class="nd-detail-value" id="nd-detail-time">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nd-menu">
|
||||||
|
<div class="nd-menu-header">画风预设</div>
|
||||||
|
<div class="nd-preset-row">
|
||||||
|
<select class="nd-preset-select" id="nd-preset-select">
|
||||||
|
${buildPresetOptions()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="nd-menu-divider"></div>
|
||||||
|
<div class="nd-menu-item" id="nd-menu-auto">
|
||||||
|
<span class="nd-item-icon">🔄</span>
|
||||||
|
<span>自动配图</span>
|
||||||
|
<span class="nd-menu-dot${isAuto ? ' active' : ''}" id="nd-menu-auto-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="nd-menu-divider"></div>
|
||||||
|
<div class="nd-menu-item" id="nd-menu-settings">
|
||||||
|
<span class="nd-item-icon">⚙️</span>
|
||||||
|
<span>设置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nd-capsule">
|
||||||
|
<div class="nd-inner">
|
||||||
|
<div class="nd-layer nd-layer-idle">
|
||||||
|
<button class="nd-btn-draw" id="nd-btn-draw" title="点击生成配图">
|
||||||
|
<span>🎨</span>
|
||||||
|
<span class="nd-auto-dot"></span>
|
||||||
|
</button>
|
||||||
|
<div class="nd-sep"></div>
|
||||||
|
<button class="nd-btn-menu" id="nd-btn-menu" title="展开菜单">
|
||||||
|
<span class="nd-arrow">▲</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="nd-layer nd-layer-active" id="nd-layer-active">
|
||||||
|
<span id="nd-status-icon">⏳</span>
|
||||||
|
<span id="nd-status-text">分析</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(floatEl);
|
||||||
|
cacheDOM();
|
||||||
|
applyPosition();
|
||||||
|
bindEvents();
|
||||||
|
|
||||||
|
window.addEventListener('resize', applyPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
const capsule = $cache.capsule;
|
||||||
|
if (!capsule) return;
|
||||||
|
|
||||||
|
capsule.addEventListener('pointerdown', onPointerDown, { passive: false });
|
||||||
|
capsule.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||||
|
capsule.addEventListener('pointerup', onPointerUp, { passive: false });
|
||||||
|
capsule.addEventListener('pointercancel', onPointerUp, { passive: false });
|
||||||
|
|
||||||
|
$cache.presetSelect?.addEventListener('change', handlePresetChange);
|
||||||
|
|
||||||
|
floatEl.querySelector('#nd-menu-auto')?.addEventListener('click', handleAutoToggle);
|
||||||
|
floatEl.querySelector('#nd-menu-settings')?.addEventListener('click', () => {
|
||||||
|
floatEl.classList.remove('expanded');
|
||||||
|
openNovelDrawSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!floatEl.contains(e.target)) {
|
||||||
|
floatEl.classList.remove('expanded', 'show-detail');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyFloatingPanel() {
|
||||||
|
clearCooldownTimer();
|
||||||
|
|
||||||
|
if (autoResetTimer) {
|
||||||
|
clearTimeout(autoResetTimer);
|
||||||
|
autoResetTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('resize', applyPosition);
|
||||||
|
|
||||||
|
floatEl?.remove();
|
||||||
|
floatEl = null;
|
||||||
|
dragState = null;
|
||||||
|
currentState = FloatState.IDLE;
|
||||||
|
$cache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 导出
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export { FloatState, setState, updateProgress, refreshPresetSelect };
|
||||||
878
modules/novel-draw/gallery-cache.js
Normal file
878
modules/novel-draw/gallery-cache.js
Normal file
@@ -0,0 +1,878 @@
|
|||||||
|
// gallery-cache.js
|
||||||
|
// 画廊和缓存管理模块
|
||||||
|
|
||||||
|
import { getContext } from "../../../../../extensions.js";
|
||||||
|
import { saveBase64AsFile } from "../../../../../utils.js";
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 常量
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const DB_NAME = 'xb_novel_draw_previews';
|
||||||
|
const DB_STORE = 'previews';
|
||||||
|
const DB_SELECTIONS_STORE = 'selections';
|
||||||
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 状态
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
let dbOpening = null;
|
||||||
|
let galleryOverlayCreated = false;
|
||||||
|
let currentGalleryData = null;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 日志
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function log(...args) {
|
||||||
|
console.log('[GalleryCache]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logDbState(label) {
|
||||||
|
log(label, {
|
||||||
|
dbExists: !!db,
|
||||||
|
dbOpening: !!dbOpening,
|
||||||
|
dbName: db?.name,
|
||||||
|
dbVersion: db?.version,
|
||||||
|
stores: db ? [...db.objectStoreNames] : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatCharacterName() {
|
||||||
|
const ctx = getContext();
|
||||||
|
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
|
||||||
|
return String(ctx.characters?.[ctx.characterId]?.name || 'character');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success', duration = 2500) {
|
||||||
|
const colors = { success: 'rgba(62,207,142,0.95)', error: 'rgba(248,113,113,0.95)', info: 'rgba(212,165,116,0.95)' };
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:' + (colors[type] || colors.info) + ';color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:99999;animation:fadeInOut ' + (duration/1000) + 's ease-in-out;max-width:80vw;text-align:center;word-break:break-all';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(function() { toast.remove(); }, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IndexedDB 操作
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function isDbValid() {
|
||||||
|
if (!db) {
|
||||||
|
log('isDbValid: db is null');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const valid = db.objectStoreNames.length > 0;
|
||||||
|
log('isDbValid:', valid);
|
||||||
|
return valid;
|
||||||
|
} catch (e) {
|
||||||
|
log('isDbValid: error', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDB() {
|
||||||
|
logDbState('openDB called');
|
||||||
|
|
||||||
|
if (dbOpening) {
|
||||||
|
log('openDB: waiting for existing open...');
|
||||||
|
return dbOpening;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDbValid()) {
|
||||||
|
if (db.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||||
|
log('openDB: reusing existing connection');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
log('openDB: missing store, closing...');
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
} catch (e) {
|
||||||
|
log('openDB: close error', e.message);
|
||||||
|
}
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('openDB: creating new connection...');
|
||||||
|
|
||||||
|
dbOpening = new Promise(function(resolve, reject) {
|
||||||
|
var request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = function() {
|
||||||
|
log('openDB: onerror', request.error);
|
||||||
|
dbOpening = null;
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = function() {
|
||||||
|
db = request.result;
|
||||||
|
log('openDB: success, version:', db.version);
|
||||||
|
|
||||||
|
db.onclose = function() {
|
||||||
|
log('openDB: onclose event!');
|
||||||
|
db = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
db.onerror = function(e) {
|
||||||
|
log('openDB: db onerror', e);
|
||||||
|
};
|
||||||
|
|
||||||
|
db.onversionchange = function() {
|
||||||
|
log('openDB: onversionchange, closing...');
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
dbOpening = null;
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = function(e) {
|
||||||
|
log('openDB: upgrade', e.oldVersion, '->', e.newVersion);
|
||||||
|
var database = e.target.result;
|
||||||
|
|
||||||
|
if (!database.objectStoreNames.contains(DB_STORE)) {
|
||||||
|
log('openDB: creating', DB_STORE);
|
||||||
|
var store = database.createObjectStore(DB_STORE, { keyPath: 'imgId' });
|
||||||
|
['messageId', 'chatId', 'timestamp', 'slotId'].forEach(function(idx) {
|
||||||
|
store.createIndex(idx, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||||
|
log('openDB: creating', DB_SELECTIONS_STORE);
|
||||||
|
database.createObjectStore(DB_SELECTIONS_STORE, { keyPath: 'slotId' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return dbOpening;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 选中状态管理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function setSlotSelection(slotId, imgId) {
|
||||||
|
log('setSlotSelection:', slotId, imgId);
|
||||||
|
var database = await openDB();
|
||||||
|
logDbState('setSlotSelection got db');
|
||||||
|
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||||
|
log('setSlotSelection: no store');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_SELECTIONS_STORE, 'readwrite');
|
||||||
|
tx.objectStore(DB_SELECTIONS_STORE).put({ slotId: slotId, selectedImgId: imgId, timestamp: Date.now() });
|
||||||
|
tx.oncomplete = function() { log('setSlotSelection: done'); resolve(); };
|
||||||
|
tx.onerror = function() { log('setSlotSelection: error', tx.error); reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('setSlotSelection: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSlotSelection(slotId) {
|
||||||
|
log('getSlotSelection:', slotId);
|
||||||
|
var database = await openDB();
|
||||||
|
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) return null;
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_SELECTIONS_STORE, 'readonly');
|
||||||
|
var request = tx.objectStore(DB_SELECTIONS_STORE).get(slotId);
|
||||||
|
request.onsuccess = function() { resolve(request.result?.selectedImgId || null); };
|
||||||
|
request.onerror = function() { reject(request.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('getSlotSelection: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSlotSelection(slotId) {
|
||||||
|
log('clearSlotSelection:', slotId);
|
||||||
|
var database = await openDB();
|
||||||
|
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) return;
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_SELECTIONS_STORE, 'readwrite');
|
||||||
|
tx.objectStore(DB_SELECTIONS_STORE).delete(slotId);
|
||||||
|
tx.oncomplete = resolve;
|
||||||
|
tx.onerror = function() { reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('clearSlotSelection: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 预览存储
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function storePreview(opts) {
|
||||||
|
var imgId = opts.imgId;
|
||||||
|
var slotId = opts.slotId;
|
||||||
|
var messageId = opts.messageId;
|
||||||
|
var base64 = opts.base64 || null;
|
||||||
|
var tags = opts.tags;
|
||||||
|
var positive = opts.positive;
|
||||||
|
var savedUrl = opts.savedUrl || null;
|
||||||
|
var status = opts.status || 'success';
|
||||||
|
var errorType = opts.errorType || null;
|
||||||
|
var errorMessage = opts.errorMessage || null;
|
||||||
|
|
||||||
|
log('storePreview:', imgId);
|
||||||
|
var database = await openDB();
|
||||||
|
logDbState('storePreview got db');
|
||||||
|
var ctx = getContext();
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||||
|
tx.objectStore(DB_STORE).put({
|
||||||
|
imgId: imgId,
|
||||||
|
slotId: slotId || imgId,
|
||||||
|
messageId: messageId,
|
||||||
|
chatId: ctx.chatId || (ctx.characterId || 'unknown'),
|
||||||
|
characterName: getChatCharacterName(),
|
||||||
|
base64: base64,
|
||||||
|
tags: tags,
|
||||||
|
positive: positive,
|
||||||
|
savedUrl: savedUrl,
|
||||||
|
status: status,
|
||||||
|
errorType: errorType,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
tx.oncomplete = function() { log('storePreview: done'); resolve(); };
|
||||||
|
tx.onerror = function() { log('storePreview: error', tx.error); reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('storePreview: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeFailedPlaceholder(opts) {
|
||||||
|
var imgId = 'failed-' + opts.slotId + '-' + Date.now();
|
||||||
|
return storePreview({
|
||||||
|
imgId: imgId,
|
||||||
|
slotId: opts.slotId,
|
||||||
|
messageId: opts.messageId,
|
||||||
|
base64: null,
|
||||||
|
tags: opts.tags,
|
||||||
|
positive: opts.positive,
|
||||||
|
status: 'failed',
|
||||||
|
errorType: opts.errorType,
|
||||||
|
errorMessage: opts.errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPreview(imgId) {
|
||||||
|
log('getPreview:', imgId);
|
||||||
|
var database = await openDB();
|
||||||
|
logDbState('getPreview got db');
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readonly');
|
||||||
|
var request = tx.objectStore(DB_STORE).get(imgId);
|
||||||
|
request.onsuccess = function() {
|
||||||
|
log('getPreview: found:', !!request.result);
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = function() {
|
||||||
|
log('getPreview: error', request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log('getPreview: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPreviewsBySlot(slotId) {
|
||||||
|
log('getPreviewsBySlot:', slotId);
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readonly');
|
||||||
|
var store = tx.objectStore(DB_STORE);
|
||||||
|
|
||||||
|
if (store.indexNames.contains('slotId')) {
|
||||||
|
var index = store.index('slotId');
|
||||||
|
var request = index.getAll(slotId);
|
||||||
|
request.onsuccess = function() {
|
||||||
|
var results = request.result || [];
|
||||||
|
if (results.length === 0) {
|
||||||
|
var allRequest = store.getAll();
|
||||||
|
allRequest.onsuccess = function() {
|
||||||
|
var allRecords = allRequest.result || [];
|
||||||
|
results = allRecords.filter(function(r) {
|
||||||
|
return r.slotId === slotId || r.imgId === slotId || (!r.slotId && r.imgId === slotId);
|
||||||
|
});
|
||||||
|
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
resolve(results);
|
||||||
|
};
|
||||||
|
allRequest.onerror = function() { reject(allRequest.error); };
|
||||||
|
} else {
|
||||||
|
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
resolve(results);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = function() { reject(request.error); };
|
||||||
|
} else {
|
||||||
|
var request2 = store.getAll();
|
||||||
|
request2.onsuccess = function() {
|
||||||
|
var allRecords = request2.result || [];
|
||||||
|
var results = allRecords.filter(function(r) { return r.slotId === slotId || r.imgId === slotId; });
|
||||||
|
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
resolve(results);
|
||||||
|
};
|
||||||
|
request2.onerror = function() { reject(request2.error); };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('getPreviewsBySlot: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDisplayPreviewForSlot(slotId) {
|
||||||
|
var previews = await getPreviewsBySlot(slotId);
|
||||||
|
if (!previews.length) return { preview: null, historyCount: 0, hasData: false, isFailed: false };
|
||||||
|
|
||||||
|
var successPreviews = previews.filter(function(p) { return p.status !== 'failed' && p.base64; });
|
||||||
|
var failedPreviews = previews.filter(function(p) { return p.status === 'failed' || !p.base64; });
|
||||||
|
|
||||||
|
if (successPreviews.length === 0) {
|
||||||
|
var latestFailed = failedPreviews[0];
|
||||||
|
return {
|
||||||
|
preview: latestFailed,
|
||||||
|
historyCount: 0,
|
||||||
|
hasData: false,
|
||||||
|
isFailed: true,
|
||||||
|
failedInfo: {
|
||||||
|
tags: latestFailed?.tags || '',
|
||||||
|
positive: latestFailed?.positive || '',
|
||||||
|
errorType: latestFailed?.errorType,
|
||||||
|
errorMessage: latestFailed?.errorMessage
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedImgId = await getSlotSelection(slotId);
|
||||||
|
if (selectedImgId) {
|
||||||
|
var selected = successPreviews.find(function(p) { return p.imgId === selectedImgId; });
|
||||||
|
if (selected) {
|
||||||
|
return { preview: selected, historyCount: successPreviews.length, hasData: true, isFailed: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preview: successPreviews[0], historyCount: successPreviews.length, hasData: true, isFailed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestPreviewForSlot(slotId) {
|
||||||
|
var result = await getDisplayPreviewForSlot(slotId);
|
||||||
|
return result.preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePreview(imgId) {
|
||||||
|
log('deletePreview:', imgId);
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||||
|
tx.objectStore(DB_STORE).delete(imgId);
|
||||||
|
tx.oncomplete = function() { log('deletePreview: done'); resolve(); };
|
||||||
|
tx.onerror = function() { reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('deletePreview: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFailedRecordsForSlot(slotId) {
|
||||||
|
var previews = await getPreviewsBySlot(slotId);
|
||||||
|
var failedRecords = previews.filter(function(p) { return p.status === 'failed' || !p.base64; });
|
||||||
|
for (var i = 0; i < failedRecords.length; i++) {
|
||||||
|
await deletePreview(failedRecords[i].imgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePreviewSavedUrl(imgId, savedUrl) {
|
||||||
|
log('updatePreviewSavedUrl:', imgId, savedUrl);
|
||||||
|
var database = await openDB();
|
||||||
|
logDbState('updatePreviewSavedUrl got db');
|
||||||
|
|
||||||
|
var preview = await getPreview(imgId);
|
||||||
|
if (!preview) {
|
||||||
|
log('updatePreviewSavedUrl: not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.savedUrl = savedUrl;
|
||||||
|
|
||||||
|
log('updatePreviewSavedUrl: re-getting db for write...');
|
||||||
|
database = await openDB();
|
||||||
|
logDbState('updatePreviewSavedUrl got db again');
|
||||||
|
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||||
|
tx.objectStore(DB_STORE).put(preview);
|
||||||
|
tx.oncomplete = function() { log('updatePreviewSavedUrl: done'); resolve(); };
|
||||||
|
tx.onerror = function() { log('updatePreviewSavedUrl: error', tx.error); reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('updatePreviewSavedUrl: tx error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCacheStats() {
|
||||||
|
log('getCacheStats');
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readonly');
|
||||||
|
var store = tx.objectStore(DB_STORE);
|
||||||
|
var countReq = store.count();
|
||||||
|
var totalSize = 0;
|
||||||
|
var successCount = 0;
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
store.openCursor().onsuccess = function(e) {
|
||||||
|
var cursor = e.target.result;
|
||||||
|
if (cursor) {
|
||||||
|
totalSize += (cursor.value.base64?.length || 0) * 0.75;
|
||||||
|
if (cursor.value.status === 'failed' || !cursor.value.base64) {
|
||||||
|
failedCount++;
|
||||||
|
} else {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.oncomplete = function() {
|
||||||
|
resolve({
|
||||||
|
count: countReq.result || 0,
|
||||||
|
successCount: successCount,
|
||||||
|
failedCount: failedCount,
|
||||||
|
sizeBytes: Math.round(totalSize),
|
||||||
|
sizeMB: (totalSize / 1024 / 1024).toFixed(2)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log('getCacheStats: error', e.message);
|
||||||
|
resolve({ count: 0, successCount: 0, failedCount: 0, sizeBytes: 0, sizeMB: '0' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearExpiredCache(cacheDays) {
|
||||||
|
cacheDays = cacheDays || 3;
|
||||||
|
log('clearExpiredCache:', cacheDays, 'days');
|
||||||
|
var cutoff = Date.now() - cacheDays * 24 * 60 * 60 * 1000;
|
||||||
|
var database = await openDB();
|
||||||
|
var deleted = 0;
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||||
|
var store = tx.objectStore(DB_STORE);
|
||||||
|
store.openCursor().onsuccess = function(e) {
|
||||||
|
var cursor = e.target.result;
|
||||||
|
if (cursor) {
|
||||||
|
var record = cursor.value;
|
||||||
|
var isExpiredUnsaved = record.timestamp < cutoff && !record.savedUrl;
|
||||||
|
var isFailed = record.status === 'failed' || !record.base64;
|
||||||
|
if (isExpiredUnsaved || (isFailed && record.timestamp < cutoff)) {
|
||||||
|
cursor.delete();
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
cursor.continue();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tx.oncomplete = function() {
|
||||||
|
log('clearExpiredCache: deleted', deleted);
|
||||||
|
resolve(deleted);
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log('clearExpiredCache: error', e.message);
|
||||||
|
resolve(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllCache() {
|
||||||
|
log('clearAllCache');
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
try {
|
||||||
|
var stores = [DB_STORE];
|
||||||
|
if (database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||||
|
stores.push(DB_SELECTIONS_STORE);
|
||||||
|
}
|
||||||
|
var tx = database.transaction(stores, 'readwrite');
|
||||||
|
tx.objectStore(DB_STORE).clear();
|
||||||
|
if (stores.length > 1) {
|
||||||
|
tx.objectStore(DB_SELECTIONS_STORE).clear();
|
||||||
|
}
|
||||||
|
tx.oncomplete = function() { log('clearAllCache: done'); resolve(); };
|
||||||
|
tx.onerror = function() { reject(tx.error); };
|
||||||
|
} catch (e) {
|
||||||
|
log('clearAllCache: error', e.message);
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGallerySummary() {
|
||||||
|
log('getGallerySummary');
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readonly');
|
||||||
|
var store = tx.objectStore(DB_STORE);
|
||||||
|
var request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = function() {
|
||||||
|
var results = request.result || [];
|
||||||
|
var summary = {};
|
||||||
|
|
||||||
|
for (var i = 0; i < results.length; i++) {
|
||||||
|
var item = results[i];
|
||||||
|
if (item.status === 'failed' || !item.base64) continue;
|
||||||
|
|
||||||
|
var charName = item.characterName || 'Unknown';
|
||||||
|
if (!summary[charName]) {
|
||||||
|
summary[charName] = { count: 0, totalSize: 0, slots: {}, latestTimestamp: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
var slotId = item.slotId || item.imgId;
|
||||||
|
if (!summary[charName].slots[slotId]) {
|
||||||
|
summary[charName].slots[slotId] = {
|
||||||
|
count: 0, hasSaved: false, latestTimestamp: 0, latestImgId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var slot = summary[charName].slots[slotId];
|
||||||
|
slot.count++;
|
||||||
|
if (item.savedUrl) slot.hasSaved = true;
|
||||||
|
if (item.timestamp > slot.latestTimestamp) {
|
||||||
|
slot.latestTimestamp = item.timestamp;
|
||||||
|
slot.latestImgId = item.imgId;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary[charName].count++;
|
||||||
|
summary[charName].totalSize += (item.base64?.length || 0) * 0.75;
|
||||||
|
if (item.timestamp > summary[charName].latestTimestamp) {
|
||||||
|
summary[charName].latestTimestamp = item.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(summary);
|
||||||
|
};
|
||||||
|
request.onerror = function() { resolve({}); };
|
||||||
|
} catch (e) {
|
||||||
|
log('getGallerySummary: error', e.message);
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCharacterPreviews(charName) {
|
||||||
|
log('getCharacterPreviews:', charName);
|
||||||
|
var database = await openDB();
|
||||||
|
return new Promise(function(resolve) {
|
||||||
|
try {
|
||||||
|
var tx = database.transaction(DB_STORE, 'readonly');
|
||||||
|
var store = tx.objectStore(DB_STORE);
|
||||||
|
var request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = function() {
|
||||||
|
var results = request.result || [];
|
||||||
|
var slots = {};
|
||||||
|
|
||||||
|
for (var i = 0; i < results.length; i++) {
|
||||||
|
var item = results[i];
|
||||||
|
if ((item.characterName || 'Unknown') !== charName) continue;
|
||||||
|
if (item.status === 'failed' || !item.base64) continue;
|
||||||
|
|
||||||
|
var slotId = item.slotId || item.imgId;
|
||||||
|
if (!slots[slotId]) slots[slotId] = [];
|
||||||
|
slots[slotId].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var sid in slots) {
|
||||||
|
slots[sid].sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(slots);
|
||||||
|
};
|
||||||
|
request.onerror = function() { resolve({}); };
|
||||||
|
} catch (e) {
|
||||||
|
log('getCharacterPreviews: error', e.message);
|
||||||
|
resolve({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 小画廊 UI
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ensureGalleryStyles() {
|
||||||
|
if (document.getElementById('nd-gallery-styles')) return;
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = 'nd-gallery-styles';
|
||||||
|
style.textContent = '#nd-gallery-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:100000;display:none;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px)}#nd-gallery-overlay.visible{display:flex;flex-direction:column;align-items:center;justify-content:center}.nd-gallery-close{position:absolute;top:16px;right:16px;width:40px;height:40px;border:none;background:rgba(255,255,255,0.1);border-radius:50%;color:#fff;font-size:20px;cursor:pointer;z-index:10}.nd-gallery-close:hover{background:rgba(255,255,255,0.2)}.nd-gallery-main{display:flex;align-items:center;gap:16px;max-width:90vw;max-height:70vh}.nd-gallery-nav{width:48px;height:48px;border:none;background:rgba(255,255,255,0.1);border-radius:50%;color:#fff;font-size:24px;cursor:pointer;flex-shrink:0}.nd-gallery-nav:hover{background:rgba(255,255,255,0.2)}.nd-gallery-nav:disabled{opacity:0.3;cursor:not-allowed}.nd-gallery-img-wrap{position:relative;max-width:calc(90vw - 140px);max-height:70vh}.nd-gallery-img{max-width:100%;max-height:70vh;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.5)}.nd-gallery-saved-badge{position:absolute;top:12px;left:12px;background:rgba(62,207,142,0.9);padding:4px 10px;border-radius:6px;font-size:11px;color:#fff;font-weight:600}.nd-gallery-thumbs{display:flex;gap:8px;margin-top:20px;padding:12px;background:rgba(0,0,0,0.3);border-radius:12px;max-width:90vw;overflow-x:auto}.nd-gallery-thumb{width:64px;height:64px;border-radius:8px;object-fit:cover;cursor:pointer;border:2px solid transparent;opacity:0.6;transition:all 0.15s;flex-shrink:0}.nd-gallery-thumb:hover{opacity:0.9}.nd-gallery-thumb.active{border-color:#d4a574;opacity:1}.nd-gallery-thumb.saved{border-color:rgba(62,207,142,0.8)}.nd-gallery-actions{display:flex;gap:12px;margin-top:16px}.nd-gallery-btn{padding:10px 20px;border:1px solid rgba(255,255,255,0.2);border-radius:8px;background:rgba(255,255,255,0.1);color:#fff;font-size:13px;cursor:pointer;transition:all 0.15s}.nd-gallery-btn:hover{background:rgba(255,255,255,0.2)}.nd-gallery-btn.primary{background:rgba(212,165,116,0.3);border-color:rgba(212,165,116,0.5)}.nd-gallery-btn.danger{color:#f87171;border-color:rgba(248,113,113,0.3)}.nd-gallery-btn.danger:hover{background:rgba(248,113,113,0.15)}.nd-gallery-info{text-align:center;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6)}';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGalleryOverlay() {
|
||||||
|
if (galleryOverlayCreated) return;
|
||||||
|
galleryOverlayCreated = true;
|
||||||
|
ensureGalleryStyles();
|
||||||
|
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.id = 'nd-gallery-overlay';
|
||||||
|
overlay.innerHTML = '<button class="nd-gallery-close" id="nd-gallery-close">\u2715</button><div class="nd-gallery-main"><button class="nd-gallery-nav" id="nd-gallery-prev">\u2039</button><div class="nd-gallery-img-wrap"><img class="nd-gallery-img" id="nd-gallery-img" src="" alt=""><div class="nd-gallery-saved-badge" id="nd-gallery-saved-badge" style="display:none">\u5DF2\u4FDD\u5B58</div></div><button class="nd-gallery-nav" id="nd-gallery-next">\u203A</button></div><div class="nd-gallery-thumbs" id="nd-gallery-thumbs"></div><div class="nd-gallery-actions" id="nd-gallery-actions"><button class="nd-gallery-btn primary" id="nd-gallery-use">\u4F7F\u7528\u6B64\u56FE</button><button class="nd-gallery-btn" id="nd-gallery-save">\uD83D\uDCBE \u4FDD\u5B58\u5230\u670D\u52A1\u5668</button><button class="nd-gallery-btn danger" id="nd-gallery-delete">\uD83D\uDDD1\uFE0F \u5220\u9664</button></div><div class="nd-gallery-info" id="nd-gallery-info"></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
document.getElementById('nd-gallery-close').addEventListener('click', closeGallery);
|
||||||
|
document.getElementById('nd-gallery-prev').addEventListener('click', function() { navigateGallery(-1); });
|
||||||
|
document.getElementById('nd-gallery-next').addEventListener('click', function() { navigateGallery(1); });
|
||||||
|
document.getElementById('nd-gallery-use').addEventListener('click', useCurrentGalleryImage);
|
||||||
|
document.getElementById('nd-gallery-save').addEventListener('click', saveCurrentGalleryImage);
|
||||||
|
document.getElementById('nd-gallery-delete').addEventListener('click', deleteCurrentGalleryImage);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', function(e) {
|
||||||
|
if (e.target === overlay) closeGallery();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openGallery(slotId, messageId, callbacks) {
|
||||||
|
callbacks = callbacks || {};
|
||||||
|
log('openGallery:', slotId, messageId);
|
||||||
|
createGalleryOverlay();
|
||||||
|
|
||||||
|
var previews = await getPreviewsBySlot(slotId);
|
||||||
|
var validPreviews = previews.filter(function(p) { return p.status !== 'failed' && p.base64; });
|
||||||
|
|
||||||
|
if (!validPreviews.length) {
|
||||||
|
showToast('\u6CA1\u6709\u627E\u5230\u56FE\u7247\u5386\u53F2', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedImgId = await getSlotSelection(slotId);
|
||||||
|
var startIndex = 0;
|
||||||
|
if (selectedImgId) {
|
||||||
|
var idx = validPreviews.findIndex(function(p) { return p.imgId === selectedImgId; });
|
||||||
|
if (idx >= 0) startIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentGalleryData = { slotId: slotId, messageId: messageId, previews: validPreviews, currentIndex: startIndex, callbacks: callbacks };
|
||||||
|
renderGallery();
|
||||||
|
document.getElementById('nd-gallery-overlay').classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGallery() {
|
||||||
|
log('closeGallery');
|
||||||
|
var el = document.getElementById('nd-gallery-overlay');
|
||||||
|
if (el) el.classList.remove('visible');
|
||||||
|
currentGalleryData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGallery() {
|
||||||
|
if (!currentGalleryData) return;
|
||||||
|
|
||||||
|
var previews = currentGalleryData.previews;
|
||||||
|
var currentIndex = currentGalleryData.currentIndex;
|
||||||
|
var current = previews[currentIndex];
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
var img = document.getElementById('nd-gallery-img');
|
||||||
|
img.src = current.savedUrl || ('data:image/png;base64,' + current.base64);
|
||||||
|
|
||||||
|
document.getElementById('nd-gallery-saved-badge').style.display = current.savedUrl ? 'block' : 'none';
|
||||||
|
|
||||||
|
var reversedPreviews = previews.slice().reverse();
|
||||||
|
var thumbsContainer = document.getElementById('nd-gallery-thumbs');
|
||||||
|
|
||||||
|
thumbsContainer.innerHTML = reversedPreviews.map(function(p, i) {
|
||||||
|
var src = p.savedUrl || ('data:image/png;base64,' + p.base64);
|
||||||
|
var originalIndex = previews.length - 1 - i;
|
||||||
|
var classes = ['nd-gallery-thumb'];
|
||||||
|
if (originalIndex === currentIndex) classes.push('active');
|
||||||
|
if (p.savedUrl) classes.push('saved');
|
||||||
|
return '<img class="' + classes.join(' ') + '" src="' + src + '" data-index="' + originalIndex + '" alt="" loading="lazy">';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
thumbsContainer.querySelectorAll('.nd-gallery-thumb').forEach(function(thumb) {
|
||||||
|
thumb.addEventListener('click', function() {
|
||||||
|
currentGalleryData.currentIndex = parseInt(thumb.dataset.index);
|
||||||
|
renderGallery();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nd-gallery-prev').disabled = currentIndex >= previews.length - 1;
|
||||||
|
document.getElementById('nd-gallery-next').disabled = currentIndex <= 0;
|
||||||
|
|
||||||
|
var saveBtn = document.getElementById('nd-gallery-save');
|
||||||
|
if (current.savedUrl) {
|
||||||
|
saveBtn.textContent = '\u2713 \u5DF2\u4FDD\u5B58';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
saveBtn.textContent = '\uD83D\uDCBE \u4FDD\u5B58\u5230\u670D\u52A1\u5668';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayVersion = previews.length - currentIndex;
|
||||||
|
var date = new Date(current.timestamp).toLocaleString();
|
||||||
|
document.getElementById('nd-gallery-info').textContent = '\u7248\u672C ' + displayVersion + ' / ' + previews.length + ' \u00B7 ' + date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateGallery(delta) {
|
||||||
|
if (!currentGalleryData) return;
|
||||||
|
var newIndex = currentGalleryData.currentIndex - delta;
|
||||||
|
if (newIndex >= 0 && newIndex < currentGalleryData.previews.length) {
|
||||||
|
currentGalleryData.currentIndex = newIndex;
|
||||||
|
renderGallery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function useCurrentGalleryImage() {
|
||||||
|
if (!currentGalleryData) return;
|
||||||
|
log('useCurrentGalleryImage');
|
||||||
|
|
||||||
|
var slotId = currentGalleryData.slotId;
|
||||||
|
var messageId = currentGalleryData.messageId;
|
||||||
|
var previews = currentGalleryData.previews;
|
||||||
|
var currentIndex = currentGalleryData.currentIndex;
|
||||||
|
var callbacks = currentGalleryData.callbacks;
|
||||||
|
var selected = previews[currentIndex];
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
await setSlotSelection(slotId, selected.imgId);
|
||||||
|
|
||||||
|
if (callbacks.onUse) callbacks.onUse(slotId, messageId, selected, previews.length);
|
||||||
|
closeGallery();
|
||||||
|
showToast('\u5DF2\u5207\u6362\u663E\u793A\u56FE\u7247');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentGalleryImage() {
|
||||||
|
if (!currentGalleryData) return;
|
||||||
|
log('saveCurrentGalleryImage');
|
||||||
|
|
||||||
|
var slotId = currentGalleryData.slotId;
|
||||||
|
var previews = currentGalleryData.previews;
|
||||||
|
var currentIndex = currentGalleryData.currentIndex;
|
||||||
|
var callbacks = currentGalleryData.callbacks;
|
||||||
|
var current = previews[currentIndex];
|
||||||
|
if (!current || current.savedUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var charName = current.characterName || getChatCharacterName();
|
||||||
|
var url = await saveBase64AsFile(current.base64, charName, 'novel_' + current.imgId, 'png');
|
||||||
|
await updatePreviewSavedUrl(current.imgId, url);
|
||||||
|
current.savedUrl = url;
|
||||||
|
|
||||||
|
await setSlotSelection(slotId, current.imgId);
|
||||||
|
|
||||||
|
showToast('\u5DF2\u4FDD\u5B58: ' + url, 'success', 4000);
|
||||||
|
renderGallery();
|
||||||
|
if (callbacks.onSave) callbacks.onSave(current.imgId, url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GalleryCache] save failed:', e);
|
||||||
|
showToast('\u4FDD\u5B58\u5931\u8D25: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentGalleryImage() {
|
||||||
|
if (!currentGalleryData) return;
|
||||||
|
log('deleteCurrentGalleryImage');
|
||||||
|
|
||||||
|
var slotId = currentGalleryData.slotId;
|
||||||
|
var messageId = currentGalleryData.messageId;
|
||||||
|
var previews = currentGalleryData.previews;
|
||||||
|
var currentIndex = currentGalleryData.currentIndex;
|
||||||
|
var callbacks = currentGalleryData.callbacks;
|
||||||
|
var current = previews[currentIndex];
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
var msg = current.savedUrl ? '\u786E\u5B9A\u5220\u9664\u8FD9\u6761\u8BB0\u5F55\u5417\uFF1F\u670D\u52A1\u5668\u4E0A\u7684\u56FE\u7247\u6587\u4EF6\u4E0D\u4F1A\u88AB\u5220\u9664\u3002' : '\u786E\u5B9A\u5220\u9664\u8FD9\u5F20\u56FE\u7247\u5417\uFF1F';
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePreview(current.imgId);
|
||||||
|
|
||||||
|
var selectedId = await getSlotSelection(slotId);
|
||||||
|
if (selectedId === current.imgId) {
|
||||||
|
await clearSlotSelection(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
previews.splice(currentIndex, 1);
|
||||||
|
|
||||||
|
if (previews.length === 0) {
|
||||||
|
closeGallery();
|
||||||
|
|
||||||
|
if (callbacks.onBecameEmpty) {
|
||||||
|
callbacks.onBecameEmpty(slotId, messageId, {
|
||||||
|
tags: current.tags || '',
|
||||||
|
positive: current.positive || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showToast('\u56FE\u7247\u5DF2\u5220\u9664\uFF0C\u53EF\u70B9\u51FB\u91CD\u8BD5\u91CD\u65B0\u751F\u6210');
|
||||||
|
} else {
|
||||||
|
if (currentGalleryData.currentIndex >= previews.length) {
|
||||||
|
currentGalleryData.currentIndex = previews.length - 1;
|
||||||
|
}
|
||||||
|
renderGallery();
|
||||||
|
if (callbacks.onDelete) callbacks.onDelete(slotId, current.imgId, previews);
|
||||||
|
showToast('\u56FE\u7247\u5DF2\u5220\u9664');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GalleryCache] delete failed:', e);
|
||||||
|
showToast('\u5220\u9664\u5931\u8D25: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 清理
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function destroyGalleryCache() {
|
||||||
|
log('destroyGalleryCache called');
|
||||||
|
console.trace('destroyGalleryCache trace');
|
||||||
|
closeGallery();
|
||||||
|
var el1 = document.getElementById('nd-gallery-overlay');
|
||||||
|
if (el1) el1.remove();
|
||||||
|
var el2 = document.getElementById('nd-gallery-styles');
|
||||||
|
if (el2) el2.remove();
|
||||||
|
galleryOverlayCreated = false;
|
||||||
|
if (db) {
|
||||||
|
try {
|
||||||
|
db.close();
|
||||||
|
log('destroyGalleryCache: closed db');
|
||||||
|
} catch (e) {
|
||||||
|
log('destroyGalleryCache: close error', e.message);
|
||||||
|
}
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
dbOpening = null;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
// Story Outline 提示词模板配置
|
// Story Outline 提示词模板配置
|
||||||
// 统一 UAUA (User-Assistant-User-Assistant) 结构
|
// 统一 UAUA (User-Assistant-User-Assistant) 结构
|
||||||
|
|
||||||
const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2';
|
|
||||||
|
|
||||||
// ================== 辅助函数 ==================
|
// ================== 辅助函数 ==================
|
||||||
const wrap = (tag, content) => content ? `<${tag}>\n${content}\n</${tag}>` : '';
|
const wrap = (tag, content) => content ? `<${tag}>\n${content}\n</${tag}>` : '';
|
||||||
@@ -22,6 +21,9 @@ const DEFAULT_JSON_TEMPLATES = {
|
|||||||
sms: `{
|
sms: `{
|
||||||
"cot": "思维链:分析角色当前的处境、与用户的关系...",
|
"cot": "思维链:分析角色当前的处境、与用户的关系...",
|
||||||
"reply": "角色用自己的语气写的回复短信内容(10-50字)"
|
"reply": "角色用自己的语气写的回复短信内容(10-50字)"
|
||||||
|
}`,
|
||||||
|
summary: `{
|
||||||
|
"summary": "只写增量总结(不要重复已有总结)"
|
||||||
}`,
|
}`,
|
||||||
invite: `{
|
invite: `{
|
||||||
"cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...",
|
"cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...",
|
||||||
@@ -174,45 +176,6 @@ const DEFAULT_JSON_TEMPLATES = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
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: `{
|
worldSimAssist: `{
|
||||||
"world": {
|
"world": {
|
||||||
"news": [
|
"news": [
|
||||||
@@ -236,24 +199,6 @@ const DEFAULT_JSON_TEMPLATES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
sceneSwitchAssist: `{
|
|
||||||
"review": {
|
|
||||||
"deviation": {
|
|
||||||
"cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。",
|
|
||||||
"score_delta": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"local_map": {
|
|
||||||
"name": "当前地点名称",
|
|
||||||
"description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。",
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"name": "节点名",
|
|
||||||
"info": "该节点的静态细节/功能描述(不写剧情事件)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
localMapGen: `{
|
localMapGen: `{
|
||||||
"review": {
|
"review": {
|
||||||
"deviation": {
|
"deviation": {
|
||||||
@@ -292,12 +237,12 @@ const DEFAULT_PROMPTS = {
|
|||||||
u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}`,
|
u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n</${v.contactName}的人物设定>` : ''}`,
|
||||||
a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`,
|
a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`,
|
||||||
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`,
|
u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`,
|
||||||
a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:`
|
a2: v => `了解,我是${v.contactName},并以模板:${JSON_TEMPLATES.sms}生成JSON:`
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`,
|
u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`,
|
||||||
a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`,
|
a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`,
|
||||||
u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n</新对话内容>\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`,
|
u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n</新对话内容>\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.summary}\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`,
|
||||||
a2: () => `了解,开始生成JSON:`
|
a2: () => `了解,开始生成JSON:`
|
||||||
},
|
},
|
||||||
invite: {
|
invite: {
|
||||||
@@ -315,7 +260,7 @@ const DEFAULT_PROMPTS = {
|
|||||||
stranger: {
|
stranger: {
|
||||||
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`,
|
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`,
|
||||||
a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以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. 无新角色返回 []`,
|
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. 无新角色返回 []\n\n\n模板:${JSON_TEMPLATES.npc}`,
|
||||||
a2: () => `了解,开始生成JSON:`
|
a2: () => `了解,开始生成JSON:`
|
||||||
},
|
},
|
||||||
worldGenStep1: {
|
worldGenStep1: {
|
||||||
@@ -364,7 +309,7 @@ const DEFAULT_PROMPTS = {
|
|||||||
|
|
||||||
输出:仅纯净合法 JSON,禁止解释文字或Markdown。`,
|
输出:仅纯净合法 JSON,禁止解释文字或Markdown。`,
|
||||||
a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`,
|
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`,
|
u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\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:`
|
a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:`
|
||||||
},
|
},
|
||||||
worldSim: {
|
worldSim: {
|
||||||
@@ -418,25 +363,9 @@ const DEFAULT_PROMPTS = {
|
|||||||
const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1);
|
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/剧情。请发送上下文。`;
|
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}`,
|
u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\nStage ${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:`
|
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: {
|
worldSimAssist: {
|
||||||
u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。
|
u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。
|
||||||
|
|
||||||
@@ -445,20 +374,6 @@ const DEFAULT_PROMPTS = {
|
|||||||
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}`,
|
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:`
|
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: {
|
localMapGen: {
|
||||||
u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。
|
u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。
|
||||||
|
|
||||||
@@ -481,7 +396,7 @@ const DEFAULT_PROMPTS = {
|
|||||||
localSceneGen: {
|
localSceneGen: {
|
||||||
u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`,
|
u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`,
|
||||||
a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`,
|
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}`,
|
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\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`,
|
||||||
a2: () => `好的,我会严格按照JSON模板生成JSON:`
|
a2: () => `好的,我会严格按照JSON模板生成JSON:`
|
||||||
},
|
},
|
||||||
localMapRefresh: {
|
localMapRefresh: {
|
||||||
@@ -494,52 +409,166 @@ const DEFAULT_PROMPTS = {
|
|||||||
|
|
||||||
export let PROMPTS = { ...DEFAULT_PROMPTS };
|
export let PROMPTS = { ...DEFAULT_PROMPTS };
|
||||||
|
|
||||||
// ================== 配置管理 ==================
|
// ================== Prompt Config (template text + ${...} expressions) ==================
|
||||||
const serializePrompts = prompts => Object.fromEntries(
|
let PROMPT_OVERRIDES = { jsonTemplates: {}, promptSources: {} };
|
||||||
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) => {
|
const normalizeNewlines = (s) => String(s ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
if (!src) return fallback;
|
const PARTS = ['u1', 'a1', 'u2', 'a2'];
|
||||||
try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; }
|
const mapParts = (fn) => Object.fromEntries(PARTS.map(p => [p, fn(p)]));
|
||||||
|
|
||||||
|
const evalExprCached = (() => {
|
||||||
|
const cache = new Map();
|
||||||
|
return (expr) => {
|
||||||
|
const key = String(expr ?? '');
|
||||||
|
if (cache.has(key)) return cache.get(key);
|
||||||
|
const fn = new Function(
|
||||||
|
'v', 'wrap', 'worldInfo', 'history', 'nameList', 'randomRange', 'safeJson', 'JSON_TEMPLATES',
|
||||||
|
`"use strict"; return (${key});`
|
||||||
|
);
|
||||||
|
cache.set(key, fn);
|
||||||
|
return fn;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const findExprEnd = (text, startIndex) => {
|
||||||
|
const s = String(text ?? '');
|
||||||
|
let depth = 1, quote = '', esc = false;
|
||||||
|
const returnDepth = [];
|
||||||
|
for (let i = startIndex; i < s.length; i++) {
|
||||||
|
const c = s[i], n = s[i + 1];
|
||||||
|
|
||||||
|
if (quote) {
|
||||||
|
if (esc) { esc = false; continue; }
|
||||||
|
if (c === '\\') { esc = true; continue; }
|
||||||
|
if (quote === '`' && c === '$' && n === '{') { depth++; returnDepth.push(depth - 1); quote = ''; i++; continue; }
|
||||||
|
if (c === quote) quote = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c === '\'' || c === '"' || c === '`') { quote = c; continue; }
|
||||||
|
if (c === '{') { depth++; continue; }
|
||||||
|
if (c === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
if (returnDepth.length && depth === returnDepth[returnDepth.length - 1]) { returnDepth.pop(); quote = '`'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hydratePrompts = sources => {
|
const renderTemplateText = (template, vars) => {
|
||||||
const out = {};
|
const s = normalizeNewlines(template);
|
||||||
Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => {
|
let out = '';
|
||||||
const s = sources?.[k] || {};
|
let i = 0;
|
||||||
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) };
|
|
||||||
});
|
while (i < s.length) {
|
||||||
|
const j = s.indexOf('${', i);
|
||||||
|
if (j === -1) return out + s.slice(i).replace(/\\\$\{/g, '${');
|
||||||
|
if (j > 0 && s[j - 1] === '\\') { out += s.slice(i, j - 1) + '${'; i = j + 2; continue; }
|
||||||
|
out += s.slice(i, j);
|
||||||
|
|
||||||
|
const end = findExprEnd(s, j + 2);
|
||||||
|
if (end === -1) return out + s.slice(j);
|
||||||
|
const expr = s.slice(j + 2, end);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const v = evalExprCached(expr)(vars, wrap, worldInfo, history, nameList, randomRange, safeJson, JSON_TEMPLATES);
|
||||||
|
out += (v === null || v === undefined) ? '' : String(v);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[StoryOutline] prompt expr error:', expr, e);
|
||||||
|
}
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPromptConfig = cfg => {
|
const replaceOutsideExpr = (text, replaceFn) => {
|
||||||
JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES };
|
const s = String(text ?? '');
|
||||||
PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts);
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const j = s.indexOf('${', i);
|
||||||
|
if (j === -1) { out += replaceFn(s.slice(i)); break; }
|
||||||
|
out += replaceFn(s.slice(i, j));
|
||||||
|
const end = findExprEnd(s, j + 2);
|
||||||
|
if (end === -1) { out += s.slice(j); break; }
|
||||||
|
out += s.slice(j, end + 1);
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY)));
|
const normalizePromptTemplateText = (raw) => {
|
||||||
const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } };
|
let s = normalizeNewlines(raw);
|
||||||
|
if (s.includes('=>') || s.includes('function')) {
|
||||||
|
const a = s.indexOf('`'), b = s.lastIndexOf('`');
|
||||||
|
if (a !== -1 && b > a) s = s.slice(a + 1, b);
|
||||||
|
}
|
||||||
|
if (!s.includes('\n') && s.includes('\\n')) {
|
||||||
|
const fn = seg => seg.replaceAll('\\n', '\n');
|
||||||
|
s = s.includes('${') ? replaceOutsideExpr(s, fn) : fn(s);
|
||||||
|
}
|
||||||
|
if (s.includes('\\t')) {
|
||||||
|
const fn = seg => seg.replaceAll('\\t', '\t');
|
||||||
|
s = s.includes('${') ? replaceOutsideExpr(s, fn) : fn(s);
|
||||||
|
}
|
||||||
|
if (s.includes('\\`')) {
|
||||||
|
const fn = seg => seg.replaceAll('\\`', '`');
|
||||||
|
s = s.includes('${') ? replaceOutsideExpr(s, fn) : fn(s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT_TEXTS = Object.fromEntries(Object.entries(DEFAULT_PROMPTS).map(([k, v]) => [k,
|
||||||
|
mapParts(p => normalizePromptTemplateText(v?.[p]?.toString?.() || '')),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const normalizePromptOverrides = (cfg) => {
|
||||||
|
const inCfg = (cfg && typeof cfg === 'object') ? cfg : {};
|
||||||
|
const inSources = inCfg.promptSources || inCfg.prompts || {};
|
||||||
|
const inJson = inCfg.jsonTemplates || {};
|
||||||
|
|
||||||
|
const promptSources = {};
|
||||||
|
Object.entries(inSources || {}).forEach(([key, srcObj]) => {
|
||||||
|
if (srcObj == null || typeof srcObj !== 'object') return;
|
||||||
|
const nextParts = {};
|
||||||
|
PARTS.forEach((part) => { if (part in srcObj) nextParts[part] = normalizePromptTemplateText(srcObj[part]); });
|
||||||
|
if (Object.keys(nextParts).length) promptSources[key] = nextParts;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jsonTemplates = {};
|
||||||
|
Object.entries(inJson || {}).forEach(([key, val]) => {
|
||||||
|
if (val == null) return;
|
||||||
|
jsonTemplates[key] = normalizeNewlines(String(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jsonTemplates, promptSources };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildPrompts = () => {
|
||||||
|
PROMPTS = Object.fromEntries(Object.entries(DEFAULT_PROMPTS).map(([k, v]) => [k,
|
||||||
|
mapParts(part => (vars) => {
|
||||||
|
const override = PROMPT_OVERRIDES?.promptSources?.[k]?.[part];
|
||||||
|
return typeof override === 'string' ? renderTemplateText(override, vars) : v?.[part]?.(vars);
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPromptConfig = (cfg) => {
|
||||||
|
PROMPT_OVERRIDES = normalizePromptOverrides(cfg);
|
||||||
|
JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES, ...(PROMPT_OVERRIDES.jsonTemplates || {}) };
|
||||||
|
rebuildPrompts();
|
||||||
|
return PROMPT_OVERRIDES;
|
||||||
|
};
|
||||||
|
|
||||||
export const getPromptConfigPayload = () => ({
|
export const getPromptConfigPayload = () => ({
|
||||||
current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) },
|
current: { jsonTemplates: PROMPT_OVERRIDES.jsonTemplates || {}, promptSources: PROMPT_OVERRIDES.promptSources || {} },
|
||||||
defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) }
|
defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: DEFAULT_PROMPT_TEXTS },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setPromptConfig = (cfg, persist = false) => {
|
export const setPromptConfig = (cfg, _persist = false) => applyPromptConfig(cfg || {});
|
||||||
applyPromptConfig(cfg || {});
|
|
||||||
const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) };
|
|
||||||
if (persist) savePromptConfigToStorage(payload);
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reloadPromptConfigFromStorage = () => {
|
applyPromptConfig({});
|
||||||
const saved = loadPromptConfigFromStorage();
|
|
||||||
applyPromptConfig(saved || {});
|
|
||||||
return getPromptConfigPayload().current;
|
|
||||||
};
|
|
||||||
|
|
||||||
reloadPromptConfigFromStorage();
|
|
||||||
|
|
||||||
// ================== 构建函数 ==================
|
// ================== 构建函数 ==================
|
||||||
const build = (type, vars) => {
|
const build = (type, vars) => {
|
||||||
@@ -560,7 +589,7 @@ export const buildExtractStrangersMessages = v => build('stranger', v);
|
|||||||
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
|
||||||
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);
|
||||||
export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', 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 buildSceneSwitchMessages = v => build('sceneSwitch', v);
|
||||||
export const buildLocalMapGenMessages = v => build('localMapGen', v);
|
export const buildLocalMapGenMessages = v => build('localMapGen', v);
|
||||||
export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v);
|
export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v);
|
||||||
export const buildLocalSceneGenMessages = v => build('localSceneGen', v);
|
export const buildLocalSceneGenMessages = v => build('localSceneGen', v);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -646,6 +646,26 @@ function getAtmosphere(store) {
|
|||||||
return store?.outlineData?.meta?.atmosphere?.current || null;
|
return store?.outlineData?.meta?.atmosphere?.current || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommonPromptVars(extra = {}) {
|
||||||
|
const store = getOutlineStore();
|
||||||
|
const comm = getCommSettings();
|
||||||
|
const mode = getGlobalSettings().mode || 'story';
|
||||||
|
const playerLocation = store?.playerLocation || store?.outlineData?.playerLocation || '未知';
|
||||||
|
return {
|
||||||
|
storyOutline: formatOutlinePrompt(),
|
||||||
|
historyCount: comm.historyCount || 50,
|
||||||
|
mode,
|
||||||
|
stage: store?.stage || 0,
|
||||||
|
deviationScore: store?.deviationScore || 0,
|
||||||
|
simulationTarget: store?.simulationTarget ?? 5,
|
||||||
|
playerLocation,
|
||||||
|
currentAtmosphere: getAtmosphere(store),
|
||||||
|
existingContacts: Array.isArray(store?.outlineData?.contacts) ? store.outlineData.contacts : [],
|
||||||
|
existingStrangers: Array.isArray(store?.outlineData?.strangers) ? store.outlineData.strangers : [],
|
||||||
|
...(extra || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 合并世界推演数据 */
|
/** 合并世界推演数据 */
|
||||||
function mergeSimData(orig, upd) {
|
function mergeSimData(orig, upd) {
|
||||||
if (!upd) return orig;
|
if (!upd) return orig;
|
||||||
@@ -687,11 +707,23 @@ const V = {
|
|||||||
inv: o => typeof o?.invite === 'boolean' && o?.reply,
|
inv: o => typeof o?.invite === 'boolean' && o?.reply,
|
||||||
sms: o => typeof o?.reply === 'string' && o.reply.length > 0,
|
sms: o => typeof o?.reply === 'string' && o.reply.length > 0,
|
||||||
wg1: d => !!d && typeof d === 'object', // 只要是对象就行,后续会 normalize
|
wg1: d => !!d && typeof d === 'object', // 只要是对象就行,后续会 normalize
|
||||||
wg2: d => !!(d?.world && (d?.maps || d?.world?.maps)?.outdoor),
|
wg2: d => !!((d?.world && (d?.maps || d?.world?.maps)?.outdoor) || (d?.outdoor && d?.inside)),
|
||||||
wga: d => !!(d?.world && d?.maps?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object',
|
wga: d => !!((d?.world && d?.maps?.outdoor) || d?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object',
|
||||||
lm: o => !!o?.inside?.name && !!o?.inside?.description
|
lm: o => !!o?.inside?.name && !!o?.inside?.description
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeStep2Maps(data) {
|
||||||
|
if (!data || typeof data !== 'object') return data;
|
||||||
|
if (data.maps || data?.world?.maps) return data;
|
||||||
|
if (!data.outdoor && !data.inside) return data;
|
||||||
|
const out = { ...data };
|
||||||
|
out.maps = { outdoor: data.outdoor, inside: data.inside };
|
||||||
|
if (!out.world || typeof out.world !== 'object') out.world = { news: [] };
|
||||||
|
delete out.outdoor;
|
||||||
|
delete out.inside;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// --- 处理器 ---
|
// --- 处理器 ---
|
||||||
|
|
||||||
async function handleFetchModels({ apiUrl, apiKey }) {
|
async function handleFetchModels({ apiUrl, apiKey }) {
|
||||||
@@ -761,7 +793,7 @@ async function handleSendSms({ requestId, contactName, worldbookUid, userMessage
|
|||||||
if (sumKeys.length) histText = `[之前的对话摘要] ${sumKeys.map(k => existSum[k]).join(';')}\n\n`;
|
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'); }
|
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 msgs = buildSmsMessages(getCommonPromptVars({ contactName, userName, smsHistoryContent: buildSmsHistoryContent(histText), userMessage, characterContent: charContent }));
|
||||||
const parsed = await callLLMJson({ messages: msgs, validate: V.sms });
|
const parsed = await callLLMJson({ messages: msgs, validate: V.sms });
|
||||||
reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' });
|
reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' });
|
||||||
} catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); }
|
} catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); }
|
||||||
@@ -800,7 +832,7 @@ async function handleCompressSms({ requestId, worldbookUid, messages, contactNam
|
|||||||
const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n');
|
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 sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b);
|
||||||
const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n');
|
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 parsed = await callLLMJson({ messages: buildSummaryMessages(getCommonPromptVars({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText })), validate: V.sum });
|
||||||
const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试');
|
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;
|
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;
|
existSum[String(nextK)] = sum;
|
||||||
@@ -817,7 +849,7 @@ async function handleCompressSms({ requestId, worldbookUid, messages, contactNam
|
|||||||
const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n');
|
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 sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b);
|
||||||
const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n');
|
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 parsed = await callLLMJson({ messages: buildSummaryMessages(getCommonPromptVars({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText })), validate: V.sum });
|
||||||
const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试');
|
const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试');
|
||||||
const newSc = toEnd;
|
const newSc = toEnd;
|
||||||
|
|
||||||
@@ -842,12 +874,12 @@ async function handleCheckStrangerWb({ requestId, strangerName }) {
|
|||||||
|
|
||||||
async function handleGenNpc({ requestId, strangerName, strangerInfo }) {
|
async function handleGenNpc({ requestId, strangerName, strangerInfo }) {
|
||||||
try {
|
try {
|
||||||
|
const comm = getCommSettings();
|
||||||
const ctx = getContext(), char = ctx.characters?.[ctx.characterId];
|
const ctx = getContext(), char = ctx.characters?.[ctx.characterId];
|
||||||
if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡');
|
if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡');
|
||||||
const primary = char.data?.extensions?.world;
|
const primary = char.data?.extensions?.world;
|
||||||
if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书');
|
if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书');
|
||||||
const comm = getCommSettings();
|
const msgs = buildNpcGenerationMessages(getCommonPromptVars({ strangerName, strangerInfo: strangerInfo || '(无描述)' }));
|
||||||
const msgs = buildNpcGenerationMessages({ strangerName, strangerInfo: strangerInfo || '(无描述)', storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50 });
|
|
||||||
const npc = await callLLMJson({ messages: msgs, validate: V.npc });
|
const npc = await callLLMJson({ messages: msgs, validate: V.npc });
|
||||||
if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据');
|
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 wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`);
|
||||||
@@ -861,8 +893,7 @@ async function handleGenNpc({ requestId, strangerName, strangerInfo }) {
|
|||||||
|
|
||||||
async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) {
|
async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) {
|
||||||
try {
|
try {
|
||||||
const comm = getCommSettings();
|
const msgs = buildExtractStrangersMessages(getCommonPromptVars({ existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] }));
|
||||||
const msgs = buildExtractStrangersMessages({ storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] });
|
|
||||||
const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr });
|
const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr });
|
||||||
if (!Array.isArray(data)) return replyErr('EXTRACT_STRANGERS_RESULT', requestId, '提取失败:无法解析 JSON 数据');
|
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 || '' }));
|
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 || '' }));
|
||||||
@@ -872,8 +903,8 @@ async function handleExtractStrangers({ requestId, existingContacts, existingStr
|
|||||||
|
|
||||||
async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) {
|
async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) {
|
||||||
try {
|
try {
|
||||||
const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story';
|
const store = getOutlineStore();
|
||||||
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 msgs = buildSceneSwitchMessages(getCommonPromptVars({ prevLocationName: prevLocationName || '未知地点', prevLocationInfo: prevLocationInfo || '', targetLocationName: targetLocationName || '未知地点', targetLocationType: targetLocationType || 'sub', targetLocationInfo: targetLocationInfo || '', playerAction: playerAction || '' }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.scene });
|
const data = await callLLMJson({ messages: msgs, validate: V.scene });
|
||||||
if (!data || !V.scene(data)) return replyErr('SCENE_SWITCH_RESULT', requestId, '场景生成失败:无法解析 JSON 数据');
|
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));
|
const delta = data.review?.deviation?.score_delta || 0, old = store?.deviationScore || 0, newS = Math.min(100, Math.max(0, old + delta));
|
||||||
@@ -897,7 +928,7 @@ async function handleSendInvite({ requestId, contactName, contactUid, targetLoca
|
|||||||
const comm = getCommSettings();
|
const comm = getCommSettings();
|
||||||
let charC = '';
|
let charC = '';
|
||||||
if (contactUid) { const es = Object.values(world_info?.entries || world_info || {}); charC = es.find(e => e.uid?.toString() === contactUid.toString())?.content || ''; }
|
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 msgs = buildInviteMessages(getCommonPromptVars({ contactName, userName: name1 || '{{user}}', targetLocation, smsHistoryContent: buildSmsHistoryContent(smsHistory || ''), characterContent: charC }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.inv });
|
const data = await callLLMJson({ messages: msgs, validate: V.inv });
|
||||||
if (typeof data?.invite !== 'boolean') return replyErr('SEND_INVITE_RESULT', requestId, '邀请处理失败:无法解析 JSON 数据');
|
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 } });
|
reply('SEND_INVITE_RESULT', requestId, { success: true, inviteData: { accepted: data.invite, reply: data.reply, targetLocation } });
|
||||||
@@ -906,7 +937,7 @@ async function handleSendInvite({ requestId, contactName, contactUid, targetLoca
|
|||||||
|
|
||||||
async function handleGenLocalMap({ requestId, outdoorDescription }) {
|
async function handleGenLocalMap({ requestId, outdoorDescription }) {
|
||||||
try {
|
try {
|
||||||
const msgs = buildLocalMapGenMessages({ storyOutline: formatOutlinePrompt(), outdoorDescription: outdoorDescription || '', historyCount: getCommSettings().historyCount || 50 });
|
const msgs = buildLocalMapGenMessages(getCommonPromptVars({ outdoorDescription: outdoorDescription || '' }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.lm });
|
const data = await callLLMJson({ messages: msgs, validate: V.lm });
|
||||||
if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据');
|
if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据');
|
||||||
tickSimCountdown(getOutlineStore());
|
tickSimCountdown(getOutlineStore());
|
||||||
@@ -916,8 +947,8 @@ async function handleGenLocalMap({ requestId, outdoorDescription }) {
|
|||||||
|
|
||||||
async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) {
|
async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) {
|
||||||
try {
|
try {
|
||||||
const store = getOutlineStore(), comm = getCommSettings();
|
const store = getOutlineStore();
|
||||||
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 msgs = buildLocalMapRefreshMessages(getCommonPromptVars({ locationName: locationName || store?.playerLocation || '未知地点', locationInfo: currentLocalMap?.description || '', currentLocalMap: currentLocalMap || null, outdoorDescription: outdoorDescription || '' }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.lm });
|
const data = await callLLMJson({ messages: msgs, validate: V.lm });
|
||||||
if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据');
|
if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据');
|
||||||
tickSimCountdown(store);
|
tickSimCountdown(store);
|
||||||
@@ -927,8 +958,8 @@ async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap,
|
|||||||
|
|
||||||
async function handleGenLocalScene({ requestId, locationName, locationInfo }) {
|
async function handleGenLocalScene({ requestId, locationName, locationInfo }) {
|
||||||
try {
|
try {
|
||||||
const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story';
|
const store = getOutlineStore();
|
||||||
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 msgs = buildLocalSceneGenMessages(getCommonPromptVars({ locationName: locationName || store?.playerLocation || '未知地点', locationInfo: locationInfo || '' }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.lscene });
|
const data = await callLLMJson({ messages: msgs, validate: V.lscene });
|
||||||
if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据');
|
if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据');
|
||||||
tickSimCountdown(store);
|
tickSimCountdown(store);
|
||||||
@@ -990,8 +1021,9 @@ async function handleGenWorld({ requestId, playerRequests }) {
|
|||||||
|
|
||||||
// 辅助模式
|
// 辅助模式
|
||||||
if (mode === 'assist') {
|
if (mode === 'assist') {
|
||||||
const msgs = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, mode: 'assist' });
|
const msgs = buildWorldGenStep2Messages(getCommonPromptVars({ playerRequests, mode: 'assist' }));
|
||||||
const wd = await callLLMJson({ messages: msgs, validate: V.wga });
|
let wd = await callLLMJson({ messages: msgs, validate: V.wga });
|
||||||
|
wd = normalizeStep2Maps(wd);
|
||||||
if (!wd?.maps?.outdoor || !Array.isArray(wd.maps.outdoor.nodes)) return replyErr('GENERATE_WORLD_RESULT', requestId, '生成失败:返回数据缺少地图节点');
|
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, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); sendSimStateOnly(); }
|
if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); sendSimStateOnly(); }
|
||||||
return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd });
|
return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd });
|
||||||
@@ -999,7 +1031,7 @@ async function handleGenWorld({ requestId, playerRequests }) {
|
|||||||
|
|
||||||
// Step 1
|
// Step 1
|
||||||
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' });
|
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' });
|
||||||
const s1m = buildWorldGenStep1Messages({ historyCount: comm.historyCount || 50, playerRequests });
|
const s1m = buildWorldGenStep1Messages(getCommonPromptVars({ playerRequests }));
|
||||||
const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 }));
|
const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 }));
|
||||||
|
|
||||||
// 简化验证 - 只要有基本数据就行
|
// 简化验证 - 只要有基本数据就行
|
||||||
@@ -1013,8 +1045,9 @@ async function handleGenWorld({ requestId, playerRequests }) {
|
|||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' });
|
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' });
|
||||||
|
|
||||||
const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, step1Data: s1d });
|
const s2m = buildWorldGenStep2Messages(getCommonPromptVars({ playerRequests, step1Data: s1d }));
|
||||||
const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 });
|
let s2d = await callLLMJson({ messages: s2m, validate: V.wg2 });
|
||||||
|
s2d = normalizeStep2Maps(s2d);
|
||||||
if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; }
|
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 失败:无法生成有效的地图');
|
if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图');
|
||||||
|
|
||||||
@@ -1034,8 +1067,9 @@ async function handleRetryStep2({ requestId }) {
|
|||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' });
|
postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' });
|
||||||
|
|
||||||
const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests: pr, step1Data: s1d });
|
const s2m = buildWorldGenStep2Messages(getCommonPromptVars({ playerRequests: pr, step1Data: s1d }));
|
||||||
const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 });
|
let s2d = await callLLMJson({ messages: s2m, validate: V.wg2 });
|
||||||
|
s2d = normalizeStep2Maps(s2d);
|
||||||
if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; }
|
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 失败:无法生成有效的地图');
|
if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图');
|
||||||
|
|
||||||
@@ -1048,8 +1082,8 @@ async function handleRetryStep2({ requestId }) {
|
|||||||
|
|
||||||
async function handleSimWorld({ requestId, currentData, isAuto }) {
|
async function handleSimWorld({ requestId, currentData, isAuto }) {
|
||||||
try {
|
try {
|
||||||
const store = getOutlineStore(), mode = getGlobalSettings().mode || 'story';
|
const store = getOutlineStore();
|
||||||
const msgs = buildWorldSimMessages({ mode, currentWorldData: currentData || '{}', historyCount: getCommSettings().historyCount || 50, deviationScore: store?.deviationScore || 0 });
|
const msgs = buildWorldSimMessages(getCommonPromptVars({ currentWorldData: currentData || '{}' }));
|
||||||
const data = await callLLMJson({ messages: msgs, validate: V.w });
|
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 数据');
|
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);
|
const orig = safe(() => JSON.parse(currentData)) || {}, merged = mergeSimData(orig, data);
|
||||||
@@ -1078,10 +1112,39 @@ function handleSaveSettings(d) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSavePrompts(d) {
|
async function handleSavePrompts(d) {
|
||||||
if (!d?.promptConfig) return;
|
// Back-compat: full payload (old iframe)
|
||||||
const payload = setPromptConfig?.(d.promptConfig, true);
|
if (d?.promptConfig) {
|
||||||
try { StoryOutlineStorage?.set?.('promptConfig', payload || d.promptConfig); } catch { }
|
const payload = setPromptConfig?.(d.promptConfig, false) || d.promptConfig;
|
||||||
|
try { await StoryOutlineStorage?.set?.('promptConfig', payload); } catch { }
|
||||||
|
postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New: incremental update by key
|
||||||
|
const key = d?.key;
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
let current = null;
|
||||||
|
try { current = await StoryOutlineStorage?.get?.('promptConfig', null); } catch { }
|
||||||
|
const next = (current && typeof current === 'object') ? {
|
||||||
|
jsonTemplates: { ...(current.jsonTemplates || {}) },
|
||||||
|
promptSources: { ...(current.promptSources || {}) },
|
||||||
|
} : { jsonTemplates: {}, promptSources: {} };
|
||||||
|
|
||||||
|
if (d?.reset) {
|
||||||
|
delete next.promptSources[key];
|
||||||
|
delete next.jsonTemplates[key];
|
||||||
|
} else {
|
||||||
|
if (d?.prompt && typeof d.prompt === 'object') next.promptSources[key] = d.prompt;
|
||||||
|
if ('jsonTemplate' in (d || {})) {
|
||||||
|
if (d.jsonTemplate == null) delete next.jsonTemplates[key];
|
||||||
|
else next.jsonTemplates[key] = String(d.jsonTemplate ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = setPromptConfig?.(next, false) || next;
|
||||||
|
try { await StoryOutlineStorage?.set?.('promptConfig', payload); } catch { }
|
||||||
postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() });
|
postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,7 +1363,7 @@ async function initPromptConfigFromServer() {
|
|||||||
try {
|
try {
|
||||||
const cfg = await StoryOutlineStorage?.get?.('promptConfig', null);
|
const cfg = await StoryOutlineStorage?.get?.('promptConfig', null);
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
setPromptConfig?.(cfg, true);
|
setPromptConfig?.(cfg, false);
|
||||||
postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() });
|
postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() });
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,11 +118,32 @@ class StreamingGeneration {
|
|||||||
return { api, model };
|
return { api, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async callAPI(generateData, abortSignal, stream = true) {
|
async callAPI(generateData, abortSignal, stream = true) {
|
||||||
const messages = Array.isArray(generateData) ? generateData :
|
const messages = Array.isArray(generateData) ? generateData :
|
||||||
(generateData?.prompt || generateData?.messages || generateData);
|
(generateData?.prompt || generateData?.messages || generateData);
|
||||||
const baseOptions = (!Array.isArray(generateData) && generateData?.apiOptions) ? generateData.apiOptions : {};
|
const baseOptions = (!Array.isArray(generateData) && generateData?.apiOptions) ? generateData.apiOptions : {};
|
||||||
const opts = { ...baseOptions, ...this.resolveCurrentApiAndModel(baseOptions) };
|
const opts = { ...baseOptions, ...this.resolveCurrentApiAndModel(baseOptions) };
|
||||||
|
|
||||||
|
const modelLower = String(opts.model || '').toLowerCase();
|
||||||
|
const isClaudeThinking = modelLower.includes('claude') && modelLower.includes('thinking');
|
||||||
|
|
||||||
|
if (isClaudeThinking && Array.isArray(messages) && messages.length > 0) {
|
||||||
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
if (lastMsg?.role === 'assistant') {
|
||||||
|
const content = String(lastMsg.content || '');
|
||||||
|
const hasCompleteThinkingBlock =
|
||||||
|
(content.includes('<thinking>') && content.includes('</thinking>')) ||
|
||||||
|
content.includes('"type":"thinking"') ||
|
||||||
|
content.includes('"type": "thinking"');
|
||||||
|
|
||||||
|
if (!hasCompleteThinkingBlock) {
|
||||||
|
console.log('[xbgen] Claude Thinking 模型:移除不完整的 assistant prefill');
|
||||||
|
messages.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const source = {
|
const source = {
|
||||||
openai: chat_completion_sources.OPENAI,
|
openai: chat_completion_sources.OPENAI,
|
||||||
claude: chat_completion_sources.CLAUDE,
|
claude: chat_completion_sources.CLAUDE,
|
||||||
@@ -132,15 +153,20 @@ class StreamingGeneration {
|
|||||||
deepseek: chat_completion_sources.DEEPSEEK,
|
deepseek: chat_completion_sources.DEEPSEEK,
|
||||||
custom: chat_completion_sources.CUSTOM,
|
custom: chat_completion_sources.CUSTOM,
|
||||||
}[String(opts.api || '').toLowerCase()];
|
}[String(opts.api || '').toLowerCase()];
|
||||||
|
|
||||||
if (!source) {
|
if (!source) {
|
||||||
|
console.error('[xbgen:callAPI] 不支持的 api:', opts.api);
|
||||||
try { xbLog.error('streamingGeneration', `unsupported api: ${opts.api}`, null); } catch {}
|
try { xbLog.error('streamingGeneration', `unsupported api: ${opts.api}`, null); } catch {}
|
||||||
}
|
}
|
||||||
if (!source) throw new Error(`不支持的 api: ${opts.api}`);
|
if (!source) throw new Error(`不支持的 api: ${opts.api}`);
|
||||||
|
|
||||||
const model = String(opts.model || '').trim();
|
const model = String(opts.model || '').trim();
|
||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
try { xbLog.error('streamingGeneration', 'missing model', null); } catch {}
|
try { xbLog.error('streamingGeneration', 'missing model', null); } catch {}
|
||||||
}
|
}
|
||||||
if (!model) throw new Error('未检测到当前模型,请在聊天面板选择模型或在插件设置中为分析显式指定模型。');
|
if (!model) throw new Error('未检测到当前模型,请在聊天面板选择模型或在插件设置中为分析显式指定模型。');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
if (xbLog.isEnabled?.()) {
|
if (xbLog.isEnabled?.()) {
|
||||||
@@ -166,6 +192,7 @@ class StreamingGeneration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const num = (v) => {
|
const num = (v) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : undefined;
|
return Number.isFinite(n) ? n : undefined;
|
||||||
@@ -191,6 +218,7 @@ class StreamingGeneration {
|
|||||||
const effectiveTopP = isUnset('top_p') ? undefined : (tpUser ?? (source === chat_completion_sources.MAKERSUITE ? tpUI_Gemini : tpUI_OpenAI));
|
const effectiveTopP = isUnset('top_p') ? undefined : (tpUser ?? (source === chat_completion_sources.MAKERSUITE ? tpUI_Gemini : tpUI_OpenAI));
|
||||||
const effectiveTopK = isUnset('top_k') ? undefined : (tkUser ?? (source === chat_completion_sources.MAKERSUITE ? tkUI_Gemini : undefined));
|
const effectiveTopK = isUnset('top_k') ? undefined : (tkUser ?? (source === chat_completion_sources.MAKERSUITE ? tkUI_Gemini : undefined));
|
||||||
const effectiveMaxT = isUnset('max_tokens') ? undefined : (mtUser ?? (source === chat_completion_sources.MAKERSUITE ? (mtUI_Gemini ?? mtUI_OpenAI) : mtUI_OpenAI) ?? 4000);
|
const effectiveMaxT = isUnset('max_tokens') ? undefined : (mtUser ?? (source === chat_completion_sources.MAKERSUITE ? (mtUI_Gemini ?? mtUI_OpenAI) : mtUI_OpenAI) ?? 4000);
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
messages, model, stream,
|
messages, model, stream,
|
||||||
chat_completion_source: source,
|
chat_completion_source: source,
|
||||||
@@ -200,7 +228,19 @@ class StreamingGeneration {
|
|||||||
top_p: effectiveTopP,
|
top_p: effectiveTopP,
|
||||||
max_tokens: effectiveMaxT,
|
max_tokens: effectiveMaxT,
|
||||||
stop: Array.isArray(generateData?.stop) ? generateData.stop : undefined,
|
stop: Array.isArray(generateData?.stop) ? generateData.stop : undefined,
|
||||||
|
use_makersuite_sysprompt: false,
|
||||||
|
claude_use_sysprompt: oai_settings?.claude_use_sysprompt ?? false,
|
||||||
|
custom_prompt_post_processing: undefined,
|
||||||
|
// thinking 模型支持
|
||||||
|
include_reasoning: oai_settings?.show_thoughts ?? true,
|
||||||
|
reasoning_effort: oai_settings?.reasoning_effort || 'medium',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Claude 专用:top_k
|
||||||
|
if (source === chat_completion_sources.CLAUDE) {
|
||||||
|
body.top_k = Number(oai_settings?.top_k_openai) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (source === chat_completion_sources.MAKERSUITE) {
|
if (source === chat_completion_sources.MAKERSUITE) {
|
||||||
if (effectiveTopK !== undefined) body.top_k = effectiveTopK;
|
if (effectiveTopK !== undefined) body.top_k = effectiveTopK;
|
||||||
body.max_output_tokens = effectiveMaxT;
|
body.max_output_tokens = effectiveMaxT;
|
||||||
@@ -239,30 +279,63 @@ class StreamingGeneration {
|
|||||||
if (oai_settings?.custom_include_body) body.custom_include_body = oai_settings.custom_include_body;
|
if (oai_settings?.custom_include_body) body.custom_include_body = oai_settings.custom_include_body;
|
||||||
if (oai_settings?.custom_exclude_body) body.custom_exclude_body = oai_settings.custom_exclude_body;
|
if (oai_settings?.custom_exclude_body) body.custom_exclude_body = oai_settings.custom_exclude_body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bodyLog = { ...body, messages: `[${body.messages?.length || 0} messages]` };
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
// 流式:走 ChatCompletionService 统一链路
|
|
||||||
const payload = ChatCompletionService.createRequestData(body);
|
const payload = ChatCompletionService.createRequestData(body);
|
||||||
|
|
||||||
const streamFactory = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
const streamFactory = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
||||||
|
|
||||||
const generator = (typeof streamFactory === 'function') ? streamFactory() : streamFactory;
|
const generator = (typeof streamFactory === 'function') ? streamFactory() : streamFactory;
|
||||||
|
|
||||||
return (async function* () {
|
return (async function* () {
|
||||||
let last = '';
|
let last = '';
|
||||||
|
let chunkCount = 0;
|
||||||
try {
|
try {
|
||||||
for await (const item of (generator || [])) {
|
for await (const item of (generator || [])) {
|
||||||
if (abortSignal?.aborted) return;
|
chunkCount++;
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkCount <= 5 || chunkCount % 20 === 0) {
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
accumulated = item;
|
accumulated = item;
|
||||||
} else if (item && typeof item === 'object') {
|
} else if (item && typeof item === 'object') {
|
||||||
|
// 尝试多种字段
|
||||||
accumulated = (typeof item.text === 'string' ? item.text : '') ||
|
accumulated = (typeof item.text === 'string' ? item.text : '') ||
|
||||||
(typeof item.content === 'string' ? item.content : '') || '';
|
(typeof item.content === 'string' ? item.content : '') || '';
|
||||||
|
|
||||||
|
// thinking 相关字段
|
||||||
|
if (!accumulated) {
|
||||||
|
const thinking = item?.delta?.thinking || item?.thinking;
|
||||||
|
if (typeof thinking === 'string') {
|
||||||
|
accumulated = thinking;
|
||||||
}
|
}
|
||||||
if (!accumulated && item && typeof item === 'object') {
|
}
|
||||||
|
if (!accumulated) {
|
||||||
const rc = item?.reasoning_content || item?.reasoning;
|
const rc = item?.reasoning_content || item?.reasoning;
|
||||||
|
if (typeof rc === 'string') {
|
||||||
|
accumulated = rc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accumulated) {
|
||||||
|
const rc = item?.choices?.[0]?.delta?.reasoning_content;
|
||||||
if (typeof rc === 'string') accumulated = rc;
|
if (typeof rc === 'string') accumulated = rc;
|
||||||
}
|
}
|
||||||
if (!accumulated) continue;
|
}
|
||||||
|
|
||||||
|
if (!accumulated) {
|
||||||
|
if (chunkCount <= 5) {
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (accumulated.startsWith(last)) {
|
if (accumulated.startsWith(last)) {
|
||||||
last = accumulated;
|
last = accumulated;
|
||||||
@@ -272,29 +345,38 @@ class StreamingGeneration {
|
|||||||
yield last;
|
yield last;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[xbgen:stream] 流式错误:', err);
|
||||||
|
console.error('[xbgen:stream] err.name:', err?.name);
|
||||||
|
console.error('[xbgen:stream] err.message:', err?.message);
|
||||||
if (err?.name === 'AbortError') return;
|
if (err?.name === 'AbortError') return;
|
||||||
console.error('[StreamingGeneration] Stream error:', err);
|
|
||||||
try { xbLog.error('streamingGeneration', 'Stream error', err); } catch {}
|
try { xbLog.error('streamingGeneration', 'Stream error', err); } catch {}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
} else {
|
} else {
|
||||||
// 非流式:extract=true,返回抽取后的结果
|
|
||||||
const payload = ChatCompletionService.createRequestData(body);
|
const payload = ChatCompletionService.createRequestData(body);
|
||||||
const extracted = await ChatCompletionService.sendRequest(payload, true, abortSignal);
|
const extracted = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
||||||
|
|
||||||
let result = String((extracted && extracted.content) || '');
|
let result = '';
|
||||||
|
if (extracted && typeof extracted === 'object') {
|
||||||
// reasoning_content 兜底
|
const msg = extracted?.choices?.[0]?.message;
|
||||||
if (!result && extracted && typeof extracted === 'object') {
|
result = String(
|
||||||
const rc = extracted?.reasoning_content || extracted?.reasoning;
|
msg?.content ??
|
||||||
if (typeof rc === 'string') result = rc;
|
msg?.reasoning_content ??
|
||||||
|
extracted?.choices?.[0]?.text ??
|
||||||
|
extracted?.content ??
|
||||||
|
extracted?.reasoning_content ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = String(extracted ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async _emitPromptReady(chatArray) {
|
async _emitPromptReady(chatArray) {
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(chatArray)) {
|
if (Array.isArray(chatArray)) {
|
||||||
@@ -401,6 +483,29 @@ class StreamingGeneration {
|
|||||||
_parseCompositeParam(param) {
|
_parseCompositeParam(param) {
|
||||||
const input = String(param || '').trim();
|
const input = String(param || '').trim();
|
||||||
if (!input) return [];
|
if (!input) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(input);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const normRole = (r) => {
|
||||||
|
const x = String(r || '').trim().toLowerCase();
|
||||||
|
if (x === 'sys' || x === 'system') return 'system';
|
||||||
|
if (x === 'assistant' || x === 'asst' || x === 'ai') return 'assistant';
|
||||||
|
if (x === 'user' || x === 'u') return 'user';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const result = parsed
|
||||||
|
.filter(m => m && typeof m === 'object')
|
||||||
|
.map(m => ({ role: normRole(m.role), content: String(m.content || '') }))
|
||||||
|
.filter(m => m.role);
|
||||||
|
if (result.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
let buf = '';
|
let buf = '';
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
@@ -416,6 +521,7 @@ class StreamingGeneration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buf) parts.push(buf);
|
if (buf) parts.push(buf);
|
||||||
|
|
||||||
const normRole = (r) => {
|
const normRole = (r) => {
|
||||||
const x = String(r || '').trim().toLowerCase();
|
const x = String(r || '').trim().toLowerCase();
|
||||||
if (x === 'sys' || x === 'system') return 'system';
|
if (x === 'sys' || x === 'system') return 'system';
|
||||||
@@ -425,11 +531,14 @@ class StreamingGeneration {
|
|||||||
};
|
};
|
||||||
const extractValue = (v) => {
|
const extractValue = (v) => {
|
||||||
let s = String(v || '').trim();
|
let s = String(v || '').trim();
|
||||||
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('"') && s.endsWith('"')) || (s.startsWith('\'') && s.endsWith('\''))) {
|
if ((s.startsWith('{') && s.endsWith('}')) ||
|
||||||
|
(s.startsWith('"') && s.endsWith('"')) ||
|
||||||
|
(s.startsWith('\'') && s.endsWith('\''))) {
|
||||||
s = s.slice(1, -1);
|
s = s.slice(1, -1);
|
||||||
}
|
}
|
||||||
return s.trim();
|
return s.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const seg of parts) {
|
for (const seg of parts) {
|
||||||
const idx = seg.indexOf('=');
|
const idx = seg.indexOf('=');
|
||||||
@@ -739,18 +848,6 @@ class StreamingGeneration {
|
|||||||
return txt;
|
return txt;
|
||||||
};
|
};
|
||||||
out = await expandVarMacros(out);
|
out = await expandVarMacros(out);
|
||||||
try {
|
|
||||||
if (typeof renderStoryString === 'function') {
|
|
||||||
const r = renderStoryString(out);
|
|
||||||
if (typeof r === 'string' && r.length) out = r;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
if (typeof evaluateMacros === 'function') {
|
|
||||||
const r2 = await evaluateMacros(out);
|
|
||||||
if (typeof r2 === 'string' && r2.length) out = r2;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -872,8 +969,11 @@ class StreamingGeneration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await expandSegmentInline(topMsgs);
|
await expandSegmentInline(topMsgs);
|
||||||
|
|
||||||
await expandSegmentInline(bottomMsgs);
|
await expandSegmentInline(bottomMsgs);
|
||||||
|
|
||||||
if (typeof prompt === 'string' && prompt.trim()) {
|
if (typeof prompt === 'string' && prompt.trim()) {
|
||||||
const beforeP = await resolveHistoryPlaceholder(prompt);
|
const beforeP = await resolveHistoryPlaceholder(prompt);
|
||||||
const afterP = await this.expandInline(beforeP);
|
const afterP = await this.expandInline(beforeP);
|
||||||
@@ -906,6 +1006,7 @@ class StreamingGeneration {
|
|||||||
.concat(topMsgs.filter(m => typeof m?.content === 'string' && m.content.trim().length))
|
.concat(topMsgs.filter(m => typeof m?.content === 'string' && m.content.trim().length))
|
||||||
.concat(prompt && prompt.trim().length ? [{ role, content: prompt.trim() }] : [])
|
.concat(prompt && prompt.trim().length ? [{ role, content: prompt.trim() }] : [])
|
||||||
.concat(bottomMsgs.filter(m => typeof m?.content === 'string' && m.content.trim().length));
|
.concat(bottomMsgs.filter(m => typeof m?.content === 'string' && m.content.trim().length));
|
||||||
|
|
||||||
const common = { messages, apiOptions, stop: parsedStop };
|
const common = { messages, apiOptions, stop: parsedStop };
|
||||||
if (nonstream) {
|
if (nonstream) {
|
||||||
try { if (lock) deactivateSendButtons(); } catch {}
|
try { if (lock) deactivateSendButtons(); } catch {}
|
||||||
|
|||||||
@@ -160,6 +160,57 @@ function normalizeOpName(k) {
|
|||||||
return OP_MAP[String(k).toLowerCase().trim()] || null;
|
return OP_MAP[String(k).toLowerCase().trim()] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBumpSpec(payload) {
|
||||||
|
try {
|
||||||
|
if (payload && typeof payload === 'object' && payload.kind && Number.isFinite(Number(payload.value))) {
|
||||||
|
return { kind: String(payload.kind), value: Number(payload.value), raw: payload.raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload === 'number' && Number.isFinite(payload)) {
|
||||||
|
return { kind: 'delta', value: payload, raw: payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String(payload ?? '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
const num = Number(raw);
|
||||||
|
if (Number.isFinite(num) && !/[*/^%]/.test(raw)) {
|
||||||
|
return { kind: 'delta', value: num, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mPct = raw.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*%$/);
|
||||||
|
if (mPct) return { kind: 'percent', value: Number(mPct[1]), raw };
|
||||||
|
|
||||||
|
const mOp = raw.match(/^([*/^])\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)$/);
|
||||||
|
if (mOp) {
|
||||||
|
const op = mOp[1] === '*' ? 'mul' : (mOp[1] === '/' ? 'div' : 'pow');
|
||||||
|
return { kind: op, value: Number(mOp[2]), raw };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBumpResult(payload, currentValue) {
|
||||||
|
const spec = parseBumpSpec(payload);
|
||||||
|
if (!spec) return { ok: false, reason: 'delta-nan' };
|
||||||
|
|
||||||
|
const baseRaw = Number(currentValue);
|
||||||
|
const base = Number.isFinite(baseRaw) ? baseRaw : 0;
|
||||||
|
|
||||||
|
let next;
|
||||||
|
if (spec.kind === 'delta') next = base + spec.value;
|
||||||
|
else if (spec.kind === 'percent') next = base + (base * (spec.value / 100));
|
||||||
|
else if (spec.kind === 'mul') next = base * spec.value;
|
||||||
|
else if (spec.kind === 'div') {
|
||||||
|
if (!Number.isFinite(spec.value) || spec.value === 0) return { ok: false, reason: 'div-zero', spec };
|
||||||
|
next = base / spec.value;
|
||||||
|
} else if (spec.kind === 'pow') next = Math.pow(base, spec.value);
|
||||||
|
else return { ok: false, reason: 'delta-nan', spec };
|
||||||
|
|
||||||
|
if (!Number.isFinite(next)) return { ok: false, reason: 'result-nan', spec };
|
||||||
|
return { ok: true, base, next, delta: next - base, spec };
|
||||||
|
}
|
||||||
|
|
||||||
/* ============= 应用签名追踪 ============= */
|
/* ============= 应用签名追踪 ============= */
|
||||||
|
|
||||||
function getAppliedMap() {
|
function getAppliedMap() {
|
||||||
@@ -303,10 +354,12 @@ function parseBlock(innerText) {
|
|||||||
Array.isArray(value) ? arr.push(...value) : arr.push(value);
|
Array.isArray(value) ? arr.push(...value) : arr.push(value);
|
||||||
};
|
};
|
||||||
const putBump = (top, path, delta) => {
|
const putBump = (top, path, delta) => {
|
||||||
const n = Number(String(delta).replace(/^\+/, ''));
|
const spec = parseBumpSpec(delta);
|
||||||
if (!Number.isFinite(n)) return;
|
if (!spec) return;
|
||||||
ops.bump[top] ||= {};
|
ops.bump[top] ||= {};
|
||||||
ops.bump[top][path] = (ops.bump[top][path] ?? 0) + n;
|
const list = (ops.bump[top][path] ||= []);
|
||||||
|
if (spec.kind === 'delta') list.push(spec.value);
|
||||||
|
else list.push(spec);
|
||||||
};
|
};
|
||||||
const putDel = (top, path) => {
|
const putDel = (top, path) => {
|
||||||
ops.del[top] ||= [];
|
ops.del[top] ||= [];
|
||||||
@@ -737,7 +790,7 @@ function parseBlock(innerText) {
|
|||||||
const rel = rest.join('.');
|
const rel = rest.join('.');
|
||||||
if (curOp === 'set') putSet(top, rel, text);
|
if (curOp === 'set') putSet(top, rel, text);
|
||||||
else if (curOp === 'push') putPush(top, rel, text);
|
else if (curOp === 'push') putPush(top, rel, text);
|
||||||
else if (curOp === 'bump') putBump(top, rel, Number(text));
|
else if (curOp === 'bump') putBump(top, rel, text);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,7 +826,7 @@ function parseBlock(innerText) {
|
|||||||
for (const item of arr) putDel(top, rel ? `${rel}.${item}` : item);
|
for (const item of arr) putDel(top, rel ? `${rel}.${item}` : item);
|
||||||
}
|
}
|
||||||
else if (curOp === 'bump') {
|
else if (curOp === 'bump') {
|
||||||
for (const item of arr) putBump(top, rel, Number(item));
|
for (const item of arr) putBump(top, rel, item);
|
||||||
}
|
}
|
||||||
stack.pop();
|
stack.pop();
|
||||||
handledList = true;
|
handledList = true;
|
||||||
@@ -812,7 +865,7 @@ function parseBlock(innerText) {
|
|||||||
putDel(top, target);
|
putDel(top, target);
|
||||||
}
|
}
|
||||||
} else if (curOp === 'bump') {
|
} else if (curOp === 'bump') {
|
||||||
putBump(top, rel, Number(stripQ(rhs)));
|
putBump(top, rel, stripQ(rhs));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -848,7 +901,7 @@ function parseBlock(innerText) {
|
|||||||
} else if (curOp === 'del') {
|
} else if (curOp === 'del') {
|
||||||
putDel(top, rel ? `${rel}.${val}` : val);
|
putDel(top, rel ? `${rel}.${val}` : val);
|
||||||
} else if (curOp === 'bump') {
|
} else if (curOp === 'bump') {
|
||||||
putBump(top, rel, Number(val));
|
putBump(top, rel, val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1098,7 @@ function getEffectiveParentNode(p) {
|
|||||||
/**
|
/**
|
||||||
* 守护验证
|
* 守护验证
|
||||||
*/
|
*/
|
||||||
export function guardValidate(op, absPath, payload) {
|
export function guardValidate(op, absPath, payload, currentOverride) {
|
||||||
if (guardianState.bypass) return { allow: true, value: payload };
|
if (guardianState.bypass) return { allow: true, value: payload };
|
||||||
|
|
||||||
const p = normalizePath(absPath);
|
const p = normalizePath(absPath);
|
||||||
@@ -1126,10 +1179,19 @@ export function guardValidate(op, absPath, payload) {
|
|||||||
|
|
||||||
// 增量操作
|
// 增量操作
|
||||||
if (op === 'bump') {
|
if (op === 'bump') {
|
||||||
let d = Number(payload);
|
const effectiveCurrent = (currentOverride !== undefined) ? currentOverride : currentValue;
|
||||||
if (!Number.isFinite(d)) return { allow: false, reason: 'delta-nan' };
|
const computed = computeBumpResult(payload, effectiveCurrent);
|
||||||
|
if (!computed?.ok) {
|
||||||
|
if (xbLog.isEnabled?.()) {
|
||||||
|
try { xbLog.warn(MODULE_ID, `bump payload rejected: path=${p} reason=${computed?.reason || 'delta-nan'}`); } catch {}
|
||||||
|
}
|
||||||
|
return { allow: false, reason: computed?.reason || 'delta-nan' };
|
||||||
|
}
|
||||||
|
|
||||||
if (currentValue === undefined) {
|
let d = computed.delta;
|
||||||
|
const base = computed.base;
|
||||||
|
|
||||||
|
if (effectiveCurrent === undefined) {
|
||||||
if (parentPath) {
|
if (parentPath) {
|
||||||
const lastSeg = p.split('.').pop() || '';
|
const lastSeg = p.split('.').pop() || '';
|
||||||
const isIndex = /^\d+$/.test(lastSeg);
|
const isIndex = /^\d+$/.test(lastSeg);
|
||||||
@@ -1152,16 +1214,16 @@ export function guardValidate(op, absPath, payload) {
|
|||||||
if (d < -step) d = -step;
|
if (d < -step) d = -step;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = Number(currentValue);
|
const cur = Number(effectiveCurrent);
|
||||||
if (!Number.isFinite(cur)) {
|
if (!Number.isFinite(cur)) {
|
||||||
const base = 0 + d;
|
const baseValue = 0 + d;
|
||||||
const cl = clampNumberWithConstraints(base, node);
|
const cl = clampNumberWithConstraints(baseValue, node);
|
||||||
if (!cl.ok) return { allow: false, reason: 'number-constraint' };
|
if (!cl.ok) return { allow: false, reason: 'number-constraint' };
|
||||||
setTypeLockIfUnknown(p, base);
|
setTypeLockIfUnknown(p, cl.value);
|
||||||
return { allow: true, value: cl.value };
|
return { allow: true, value: cl.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = cur + d;
|
const next = Number.isFinite(base) ? (base + d) : (cur + d);
|
||||||
const clamped = clampNumberWithConstraints(next, node);
|
const clamped = clampNumberWithConstraints(next, node);
|
||||||
if (!clamped.ok) return { allow: false, reason: 'number-constraint' };
|
if (!clamped.ok) return { allow: false, reason: 'number-constraint' };
|
||||||
return { allow: true, value: clamped.value };
|
return { allow: true, value: clamped.value };
|
||||||
@@ -1329,11 +1391,7 @@ export function rulesLoadFromTree(valueTree, basePath) {
|
|||||||
String(t).trim().startsWith('$') ? String(t).trim() : ('$' + String(t).trim())
|
String(t).trim().startsWith('$') ? String(t).trim() : ('$' + String(t).trim())
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseNorm = normalizePath(curAbs || '');
|
const targetPath = curAbs ? `${curAbs}.${targetToken}` : targetToken;
|
||||||
const tokenNorm = normalizePath(targetToken);
|
|
||||||
const targetPath = (baseNorm && (tokenNorm === baseNorm || tokenNorm.startsWith(baseNorm + '.')))
|
|
||||||
? tokenNorm
|
|
||||||
: (curAbs ? `${curAbs}.${targetToken}` : targetToken);
|
|
||||||
const absPath = normalizePath(targetPath);
|
const absPath = normalizePath(targetPath);
|
||||||
const delta = parseDirectivesTokenList(dirs);
|
const delta = parseDirectivesTokenList(dirs);
|
||||||
|
|
||||||
@@ -2036,19 +2094,15 @@ async function applyVariablesForMessage(messageId) {
|
|||||||
// BUMP 操作
|
// BUMP 操作
|
||||||
else if (op.operation === 'bump') {
|
else if (op.operation === 'bump') {
|
||||||
for (const [k, delta] of Object.entries(op.data)) {
|
for (const [k, delta] of Object.entries(op.data)) {
|
||||||
const num = Number(delta);
|
|
||||||
if (!Number.isFinite(num)) continue;
|
|
||||||
|
|
||||||
const localPath = joinPath(subPath, k);
|
const localPath = joinPath(subPath, k);
|
||||||
const absPath = localPath ? `${root}.${localPath}` : root;
|
const absPath = localPath ? `${root}.${localPath}` : root;
|
||||||
const stdPath = normalizePath(absPath);
|
const stdPath = normalizePath(absPath);
|
||||||
|
const deltaList = Array.isArray(delta) ? delta : [delta];
|
||||||
|
|
||||||
let allow = true;
|
for (const entry of deltaList) {
|
||||||
let useDelta = num;
|
const spec = parseBumpSpec(entry);
|
||||||
|
if (!spec) continue;
|
||||||
|
|
||||||
const res = guardValidate('bump', stdPath, num);
|
|
||||||
allow = !!res?.allow;
|
|
||||||
if (allow && 'value' in res && Number.isFinite(res.value)) {
|
|
||||||
let curr;
|
let curr;
|
||||||
try {
|
try {
|
||||||
const pth = norm(localPath || '');
|
const pth = norm(localPath || '');
|
||||||
@@ -2062,20 +2116,42 @@ async function applyVariablesForMessage(messageId) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const baseNum = Number(curr);
|
const res = guardValidate('bump', stdPath, spec, curr);
|
||||||
const targetNum = Number(res.value);
|
const allow = !!res?.allow;
|
||||||
useDelta = (Number.isFinite(targetNum) ? targetNum : num) - (Number.isFinite(baseNum) ? baseNum : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!allow) {
|
if (!allow) {
|
||||||
guardDenied++;
|
guardDenied++;
|
||||||
if (debugOn && guardDeniedSamples.length < 8) guardDeniedSamples.push({ op: 'bump', path: stdPath });
|
if (debugOn && guardDeniedSamples.length < 8) guardDeniedSamples.push({ op: 'bump', path: stdPath });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseNum = Number(curr);
|
||||||
|
const targetNum = Number(res.value);
|
||||||
|
const useDelta = Number.isFinite(targetNum)
|
||||||
|
? (targetNum - (Number.isFinite(baseNum) ? baseNum : 0))
|
||||||
|
: (spec.kind === 'delta' ? spec.value : NaN);
|
||||||
|
|
||||||
|
if (!Number.isFinite(useDelta)) {
|
||||||
|
guardDenied++;
|
||||||
|
if (debugOn && guardDeniedSamples.length < 8) guardDeniedSamples.push({ op: 'bump', path: stdPath });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugOn && spec.kind !== 'delta') {
|
||||||
|
try {
|
||||||
|
const baseVal = Number.isFinite(baseNum) ? baseNum : 0;
|
||||||
|
const nextVal = Number.isFinite(targetNum) ? targetNum : (baseVal + useDelta);
|
||||||
|
const expr = (spec.raw !== undefined && spec.raw !== null && String(spec.raw).trim() !== '')
|
||||||
|
? String(spec.raw)
|
||||||
|
: `${spec.kind}:${spec.value}`;
|
||||||
|
xbLog.info(MODULE_ID, `plot-log bump-math path=${stdPath} base=${baseVal} expr=${expr} next=${nextVal}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
bumpAtPath(rec, norm(localPath || ''), useDelta);
|
bumpAtPath(rec, norm(localPath || ''), useDelta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有变化
|
// 检查是否有变化
|
||||||
const hasChanges = Array.from(byName.values()).some(rec => rec?.changed === true);
|
const hasChanges = Array.from(byName.values()).some(rec => rec?.changed === true);
|
||||||
|
|||||||
@@ -116,54 +116,12 @@
|
|||||||
<input type="checkbox" id="xiaobaix_immersive_enabled" />
|
<input type="checkbox" id="xiaobaix_immersive_enabled" />
|
||||||
<label for="xiaobaix_immersive_enabled" class="has-tooltip" data-tooltip="重构界面布局与细节优化">沉浸布局显示(边框窄化)</label>
|
<label for="xiaobaix_immersive_enabled" class="has-tooltip" data-tooltip="重构界面布局与细节优化">沉浸布局显示(边框窄化)</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-container">
|
|
||||||
<input type="checkbox" id="wallhaven_enabled" />
|
|
||||||
<label for="wallhaven_enabled" class="has-tooltip" data-tooltip="AI回复时自动提取消息内容,转换为标签并获取Wallhaven图片作为聊天背景">自动消息配背景图</label>
|
|
||||||
</div>
|
|
||||||
<div id="wallhaven_settings_container" style="display:none;">
|
|
||||||
<div class="flex-container">
|
|
||||||
<input type="checkbox" id="wallhaven_bg_mode" />
|
|
||||||
<label for="wallhaven_bg_mode">背景图模式(纯场景)</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex-container">
|
|
||||||
<label for="wallhaven_category" id="section-font">图片分类:</label>
|
|
||||||
<select id="wallhaven_category" class="text_pole">
|
|
||||||
<option value="010">动漫漫画</option>
|
|
||||||
<option value="111">全部类型</option>
|
|
||||||
<option value="001">人物写真</option>
|
|
||||||
<option value="100">综合壁纸</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex-container">
|
|
||||||
<label for="wallhaven_purity" id="section-font">内容分级:</label>
|
|
||||||
<select id="wallhaven_purity" class="text_pole">
|
|
||||||
<option value="100">仅 SFW</option>
|
|
||||||
<option value="010">仅 Sketchy (轻微)</option>
|
|
||||||
<option value="110">SFW + Sketchy</option>
|
|
||||||
<option value="001">仅 NSFW</option>
|
|
||||||
<option value="011">Sketchy + NSFW</option>
|
|
||||||
<option value="111">全部内容</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex-container">
|
|
||||||
<label for="wallhaven_opacity" id="section-font">黑纱透明度: <span id="wallhaven_opacity_value">30%</span></label>
|
|
||||||
<input type="range" id="wallhaven_opacity" min="0" max="0.8" step="0.1" value="0.3" class="wide50p" />
|
|
||||||
</div>
|
|
||||||
<hr class="sysHR">
|
|
||||||
<div class="flex-container">
|
|
||||||
<input type="text" id="wallhaven_custom_tag_input" placeholder="输入英文标签,如: beautiful girl" class="text_pole wide50p" />
|
|
||||||
<button id="wallhaven_add_custom_tag" class="menu_button" type="button" style="width:auto;">+自定义TAG</button>
|
|
||||||
</div>
|
|
||||||
<div id="wallhaven_custom_tags_container" class="custom-tags-container">
|
|
||||||
<div id="wallhaven_custom_tags_list" class="custom-tags-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="section-divider">Novel 画图
|
<div class="section-divider">Novel 画图
|
||||||
<hr class="sysHR" />
|
<hr class="sysHR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-container">
|
<div class="flex-container">
|
||||||
<input type="checkbox" id="xiaobaix_novel_draw_enabled" />
|
<input type="checkbox" id="xiaobaix_novel_draw_enabled" />
|
||||||
<label for="xiaobaix_novel_draw_enabled" class="has-tooltip" data-tooltip="使用 NovelAI 为 AI 回复自动或手动生成配图,需在酒馆设置中配置 NovelAI Key">启用 Novel 画图(暂不可用)</label>
|
<label for="xiaobaix_novel_draw_enabled" class="has-tooltip" data-tooltip="使用 NovelAI 为 AI 回复自动或手动生成配图,需在酒馆设置中配置 NovelAI Key">启用 Novel 画图</label>
|
||||||
<button id="xiaobaix_novel_draw_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开 Novel 画图详细设置">
|
<button id="xiaobaix_novel_draw_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开 Novel 画图详细设置">
|
||||||
<i class="fa-solid fa-palette"></i>
|
<i class="fa-solid fa-palette"></i>
|
||||||
<small>画图设置</small>
|
<small>画图设置</small>
|
||||||
@@ -544,7 +502,6 @@
|
|||||||
scriptAssistant: 'xiaobaix_script_assistant',
|
scriptAssistant: 'xiaobaix_script_assistant',
|
||||||
tasks: 'scheduled_tasks_enabled',
|
tasks: 'scheduled_tasks_enabled',
|
||||||
templateEditor: 'xiaobaix_template_enabled',
|
templateEditor: 'xiaobaix_template_enabled',
|
||||||
wallhaven: 'wallhaven_enabled',
|
|
||||||
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
||||||
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
||||||
variablesCore: 'xiaobaix_variables_core_enabled',
|
variablesCore: 'xiaobaix_variables_core_enabled',
|
||||||
@@ -557,8 +514,8 @@
|
|||||||
renderEnabled: 'xiaobaix_render_enabled',
|
renderEnabled: 'xiaobaix_render_enabled',
|
||||||
};
|
};
|
||||||
const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
|
const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
|
||||||
const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw'];
|
const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw'];
|
||||||
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'wallhaven', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw'];
|
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw'];
|
||||||
function setModuleEnabled(key, enabled) {
|
function setModuleEnabled(key, enabled) {
|
||||||
try {
|
try {
|
||||||
if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};
|
if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user