Add files via upload
This commit is contained in:
131
README.md
131
README.md
@@ -5,66 +5,81 @@ SillyTavern 扩展插件 - 小白X
|
|||||||
## 📁 目录结构
|
## 📁 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
LittleWhiteBox/
|
LittleWhiteBox/
|
||||||
├── manifest.json # 插件配置清单
|
├── index.js # 主入口,初始化所有模块,管理总开关
|
||||||
├── index.js # 主入口文件
|
├── manifest.json # 插件清单,版本、依赖声明
|
||||||
├── settings.html # 设置页面模板
|
├── settings.html # 主设置页面,所有模块开关UI
|
||||||
├── style.css # 全局样式
|
├── style.css # 全局样式
|
||||||
│
|
├── README.md # 说明文档
|
||||||
├── modules/ # 功能模块目录
|
│
|
||||||
│ ├── streaming-generation.js # 流式生成
|
├── core/ # 核心公共模块
|
||||||
│ ├── dynamic-prompt.js # 动态提示词
|
│ ├── constants.js # 共享常量 EXT_ID, extensionFolderPath
|
||||||
│ ├── immersive-mode.js # 沉浸模式
|
│ ├── event-manager.js # 统一事件管理,createModuleEvents()
|
||||||
│ ├── message-preview.js # 消息预览
|
│ ├── debug-core.js # 日志 xbLog + 缓存注册 CacheRegistry
|
||||||
│ ├── wallhaven-background.js # 壁纸背景
|
│ ├── slash-command.js # 斜杠命令执行封装
|
||||||
│ ├── button-collapse.js # 按钮折叠
|
│ ├── variable-path.js # 变量路径解析工具
|
||||||
│ ├── control-audio.js # 音频控制
|
│ └── server-storage.js # 服务器文件存储,防抖保存,自动重试
|
||||||
│ ├── script-assistant.js # 脚本助手
|
│
|
||||||
│ │
|
├── modules/ # 功能模块
|
||||||
│ ├── variables/ # 变量系统
|
│ ├── button-collapse.js # 按钮折叠,消息区按钮收纳
|
||||||
│ │ ├── variables-core.js
|
│ ├── control-audio.js # 音频控制,iframe音频权限
|
||||||
│ │ └── variables-panel.js
|
│ ├── iframe-renderer.js # iframe渲染,代码块转交互界面
|
||||||
│ │
|
│ ├── immersive-mode.js # 沉浸模式,界面布局优化
|
||||||
│ ├── template-editor/ # 模板编辑器
|
│ ├── message-preview.js # 消息预览,Log记录/拦截
|
||||||
│ │ ├── template-editor.js
|
│ ├── script-assistant.js # 脚本助手,AI写卡知识注入
|
||||||
│ │ └── template-editor.html
|
│ ├── streaming-generation.js # 流式生成,xbgenraw命令
|
||||||
│ │
|
│ │
|
||||||
│ ├── scheduled-tasks/ # 定时任务
|
│ ├── debug-panel/ # 调试面板模块
|
||||||
│ │ ├── scheduled-tasks.js
|
│ │ ├── debug-panel.js # 悬浮窗控制,父子通信,懒加载
|
||||||
│ │ ├── scheduled-tasks.html
|
│ │ └── debug-panel.html # 三Tab界面:日志/事件/缓存
|
||||||
│ │ └── embedded-tasks.html
|
│ │
|
||||||
│ │
|
│ ├── fourth-wall/ # 四次元壁模块(皮下交流)
|
||||||
│ ├── story-summary/ # 故事摘要
|
│ │ ├── fourth-wall.js # 悬浮按钮,postMessage通讯
|
||||||
│ │ ├── story-summary.js
|
│ │ └── fourth-wall.html # iframe聊天界面,提示词编辑
|
||||||
│ │ └── story-summary.html
|
│ │
|
||||||
│ │
|
│ ├── novel-draw/ # Novel画图模块
|
||||||
│ └── story-outline/ # 故事大纲
|
│ │ ├── novel-draw.js # NovelAI画图,预设管理,LLM场景分析
|
||||||
│ ├── story-outline.js
|
│ │ ├── novel-draw.html # 参数配置,图片管理(画廊+缓存)
|
||||||
│ ├── story-outline-prompt.js
|
│ │ ├── floating-panel.js # 悬浮面板,状态显示,快捷操作
|
||||||
│ └── story-outline.html
|
│ │ └── gallery-cache.js # IndexedDB缓存,小画廊UI
|
||||||
│
|
│ │
|
||||||
├── bridges/ # 外部桥接模块
|
│ ├── scheduled-tasks/ # 定时任务模块
|
||||||
│ ├── worldbook-bridge.js # 世界书桥接
|
│ │ ├── scheduled-tasks.js # 全局/角色/预设任务调度
|
||||||
│ ├── call-generate-service.js # 生成服务调用
|
│ │ ├── scheduled-tasks.html # 任务设置面板
|
||||||
│ └── wrapper-iframe.js # iframe 包装器
|
│ │ └── embedded-tasks.html # 嵌入式任务界面
|
||||||
│
|
│ │
|
||||||
├── ui/ # UI 模板
|
│ ├── template-editor/ # 模板编辑器模块
|
||||||
│ └── character-updater-menus.html
|
│ │ ├── template-editor.js # 沉浸式模板,流式多楼层渲染
|
||||||
│
|
│ │ └── template-editor.html # 模板编辑界面
|
||||||
└── docs/ # 文档
|
│ │
|
||||||
├── script-docs.md # 脚本文档
|
│ ├── story-outline/ # 故事大纲模块
|
||||||
├── LICENSE.md # 许可证
|
│ │ ├── story-outline.js # 可视化剧情地图
|
||||||
├── COPYRIGHT # 版权信息
|
│ │ ├── story-outline.html # 大纲编辑界面
|
||||||
└── NOTICE # 声明
|
│ │ └── story-outline-prompt.js # 大纲生成提示词
|
||||||
|
│ │
|
||||||
|
│ ├── story-summary/ # 剧情总结模块
|
||||||
|
│ │ ├── story-summary.js # 增量总结,时间线,关系图
|
||||||
|
│ │ └── story-summary.html # 总结面板界面
|
||||||
|
│ │
|
||||||
|
│ └── variables/ # 变量系统模块
|
||||||
|
│ ├── var-commands.js # /xbgetvar /xbsetvar 命令,宏替换
|
||||||
|
│ ├── varevent-editor.js # 条件规则编辑器,varevent运行时
|
||||||
|
│ ├── variables-core.js # plot-log解析,快照回滚,变量守护
|
||||||
|
│ └── variables-panel.js # 变量面板UI
|
||||||
|
│
|
||||||
|
├── bridges/ # 外部服务桥接
|
||||||
|
│ ├── call-generate-service.js # 父窗口:调用ST生成服务
|
||||||
|
│ ├── worldbook-bridge.js # 父窗口:世界书读写桥接
|
||||||
|
│ └── wrapper-iframe.js # iframe内部:提供CallGenerate API
|
||||||
|
│
|
||||||
|
└── docs/ # 文档与许可
|
||||||
|
├── script-docs.md # 脚本文档
|
||||||
|
├── COPYRIGHT # 版权声明
|
||||||
|
├── LICENSE.md # 许可证
|
||||||
|
└── NOTICE # 通知
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 模块组织规则
|
|
||||||
|
|
||||||
- **单文件模块**:直接放在 `modules/` 目录下
|
|
||||||
- **多文件模块**:创建子目录,包含相关的 JS、HTML 等文件
|
|
||||||
- **桥接模块**:与外部系统交互的独立模块放在 `bridges/`
|
|
||||||
- **避免使用 `index.js`**:每个模块文件直接命名,不使用 `index.js`
|
|
||||||
|
|
||||||
## 🔄 版本历史
|
## 🔄 版本历史
|
||||||
|
|
||||||
- v2.2.2 - 目录结构重构(2025-12-08)
|
- 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"
|
||||||
@@ -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 || ''));
|
||||||
return { ok: false };
|
if (!tags) return;
|
||||||
}
|
|
||||||
|
const requestId = 'fwimg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
async function hydrateImageSlots(container) {
|
pendingImages.set(requestId, { slot, tags });
|
||||||
for (const slot of container.querySelectorAll('.fw-img-slot:not([data-loaded])')) {
|
|
||||||
slot.setAttribute('data-loaded', '1');
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-search"></i> 查询缓存...</div>`;
|
||||||
const raw = decodeURIComponent(slot.dataset.raw || '');
|
postToParent({ type: 'CHECK_IMAGE_CACHE', requestId, tags });
|
||||||
const { tagCSV, isNSFW } = parseImageToken(raw);
|
|
||||||
if (!tagCSV) continue;
|
|
||||||
const catMap = { anime: '010', people: '001' };
|
|
||||||
const category = catMap[state.imgSettings.categoryPreference] || '010';
|
|
||||||
const purity = isNSFW ? '001' : '111';
|
|
||||||
const cacheKey = [tagCSV, purity, category].join('|');
|
|
||||||
try {
|
|
||||||
let rec = imageCache.get(cacheKey);
|
|
||||||
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 {
|
}, {
|
||||||
slot.innerHTML = `<div class="fw-img-error"><i class="fa-solid fa-image"></i><div>无法加载图片</div><div style="font-size:10px;">${tagCSV}</div></div>`;
|
root: document.getElementById('messages'),
|
||||||
|
rootMargin: '150px 0px',
|
||||||
|
threshold: 0.01
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateImageSlots(container) {
|
||||||
|
initImageObserver();
|
||||||
|
|
||||||
|
container.querySelectorAll('.fw-img-slot:not([data-observed])').forEach(slot => {
|
||||||
|
slot.setAttribute('data-observed', '1');
|
||||||
|
|
||||||
|
if (!slot.dataset.loaded && !slot.dataset.loading) {
|
||||||
|
slot.innerHTML = `
|
||||||
|
<div class="fw-img-placeholder">
|
||||||
|
<i class="fa-regular fa-image"></i>
|
||||||
|
<span>滚动加载</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageObserver.observe(slot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }); };
|
||||||
@@ -1112,4 +1303,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,4 +1264,4 @@ if (typeof window !== 'undefined') {
|
|||||||
try { fourthWallCleanup(); } catch {}
|
try { fourthWallCleanup(); } catch {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||||
@@ -257,26 +257,32 @@ function findLastAIMessage() {
|
|||||||
return $aiMessages.length ? $($aiMessages.last()) : null;
|
return $aiMessages.length ? $($aiMessages.last()) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSingleModeMessages() {
|
function showSingleModeMessages() {
|
||||||
const $messages = $(SEL.mes);
|
const $messages = $(SEL.mes);
|
||||||
if (!$messages.length) return;
|
if (!$messages.length) return;
|
||||||
|
|
||||||
$messages.hide();
|
$messages.hide();
|
||||||
|
|
||||||
const $targetAI = findLastAIMessage();
|
const $targetAI = findLastAIMessage();
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addNavigationToLastTwoMessages() {
|
function addNavigationToLastTwoMessages() {
|
||||||
hideNavigationButtons();
|
hideNavigationButtons();
|
||||||
@@ -371,16 +377,23 @@ function updateSwipesCounter($targetMes) {
|
|||||||
}
|
}
|
||||||
$swipesCounter.html('1​/​1');
|
$swipesCounter.html('1​/​1');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDisplayMode() {
|
function scrollToBottom() {
|
||||||
if (!state.isActive) return;
|
const chatContainer = document.getElementById('chat');
|
||||||
|
if (chatContainer) {
|
||||||
const settings = getSettings();
|
requestAnimationFrame(() => {
|
||||||
settings.showAllMessages = !settings.showAllMessages;
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
applyModeClasses();
|
});
|
||||||
updateMessageDisplay();
|
}
|
||||||
saveSettingsDebounced();
|
}
|
||||||
}
|
function toggleDisplayMode() {
|
||||||
|
if (!state.isActive) return;
|
||||||
|
const settings = getSettings();
|
||||||
|
settings.showAllMessages = !settings.showAllMessages;
|
||||||
|
applyModeClasses();
|
||||||
|
updateMessageDisplay();
|
||||||
|
saveSettingsDebounced();
|
||||||
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSwipe(swipeSelector, $targetMes) {
|
function handleSwipe(swipeSelector, $targetMes) {
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceOutsideExpr = (text, replaceFn) => {
|
||||||
|
const s = String(text ?? '');
|
||||||
|
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 normalizePromptTemplateText = (raw) => {
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
return out;
|
|
||||||
|
const jsonTemplates = {};
|
||||||
|
Object.entries(inJson || {}).forEach(([key, val]) => {
|
||||||
|
if (val == null) return;
|
||||||
|
jsonTemplates[key] = normalizeNewlines(String(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jsonTemplates, promptSources };
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPromptConfig = cfg => {
|
const rebuildPrompts = () => {
|
||||||
JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES };
|
PROMPTS = Object.fromEntries(Object.entries(DEFAULT_PROMPTS).map(([k, v]) => [k,
|
||||||
PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts);
|
mapParts(part => (vars) => {
|
||||||
|
const override = PROMPT_OVERRIDES?.promptSources?.[k]?.[part];
|
||||||
|
return typeof override === 'string' ? renderTemplateText(override, vars) : v?.[part]?.(vars);
|
||||||
|
}),
|
||||||
|
]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY)));
|
const applyPromptConfig = (cfg) => {
|
||||||
const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } };
|
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
File diff suppressed because it is too large
Load Diff
@@ -118,182 +118,264 @@ class StreamingGeneration {
|
|||||||
return { api, model };
|
return { api, model };
|
||||||
}
|
}
|
||||||
|
|
||||||
async callAPI(generateData, abortSignal, stream = true) {
|
|
||||||
const messages = Array.isArray(generateData) ? generateData :
|
async callAPI(generateData, abortSignal, stream = true) {
|
||||||
(generateData?.prompt || generateData?.messages || generateData);
|
const messages = Array.isArray(generateData) ? generateData :
|
||||||
const baseOptions = (!Array.isArray(generateData) && generateData?.apiOptions) ? generateData.apiOptions : {};
|
(generateData?.prompt || generateData?.messages || generateData);
|
||||||
const opts = { ...baseOptions, ...this.resolveCurrentApiAndModel(baseOptions) };
|
const baseOptions = (!Array.isArray(generateData) && generateData?.apiOptions) ? generateData.apiOptions : {};
|
||||||
const source = {
|
const opts = { ...baseOptions, ...this.resolveCurrentApiAndModel(baseOptions) };
|
||||||
openai: chat_completion_sources.OPENAI,
|
|
||||||
claude: chat_completion_sources.CLAUDE,
|
const modelLower = String(opts.model || '').toLowerCase();
|
||||||
gemini: chat_completion_sources.MAKERSUITE,
|
const isClaudeThinking = modelLower.includes('claude') && modelLower.includes('thinking');
|
||||||
google: chat_completion_sources.MAKERSUITE,
|
|
||||||
cohere: chat_completion_sources.COHERE,
|
if (isClaudeThinking && Array.isArray(messages) && messages.length > 0) {
|
||||||
deepseek: chat_completion_sources.DEEPSEEK,
|
const lastMsg = messages[messages.length - 1];
|
||||||
custom: chat_completion_sources.CUSTOM,
|
if (lastMsg?.role === 'assistant') {
|
||||||
}[String(opts.api || '').toLowerCase()];
|
const content = String(lastMsg.content || '');
|
||||||
if (!source) {
|
const hasCompleteThinkingBlock =
|
||||||
try { xbLog.error('streamingGeneration', `unsupported api: ${opts.api}`, null); } catch {}
|
(content.includes('<thinking>') && content.includes('</thinking>')) ||
|
||||||
}
|
content.includes('"type":"thinking"') ||
|
||||||
if (!source) throw new Error(`不支持的 api: ${opts.api}`);
|
content.includes('"type": "thinking"');
|
||||||
const model = String(opts.model || '').trim();
|
|
||||||
if (!model) {
|
if (!hasCompleteThinkingBlock) {
|
||||||
try { xbLog.error('streamingGeneration', 'missing model', null); } catch {}
|
console.log('[xbgen] Claude Thinking 模型:移除不完整的 assistant prefill');
|
||||||
}
|
messages.pop();
|
||||||
if (!model) throw new Error('未检测到当前模型,请在聊天面板选择模型或在插件设置中为分析显式指定模型。');
|
}
|
||||||
try {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
openai: chat_completion_sources.OPENAI,
|
||||||
|
claude: chat_completion_sources.CLAUDE,
|
||||||
|
gemini: chat_completion_sources.MAKERSUITE,
|
||||||
|
google: chat_completion_sources.MAKERSUITE,
|
||||||
|
cohere: chat_completion_sources.COHERE,
|
||||||
|
deepseek: chat_completion_sources.DEEPSEEK,
|
||||||
|
custom: chat_completion_sources.CUSTOM,
|
||||||
|
}[String(opts.api || '').toLowerCase()];
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
console.error('[xbgen:callAPI] 不支持的 api:', opts.api);
|
||||||
|
try { xbLog.error('streamingGeneration', `unsupported api: ${opts.api}`, null); } catch {}
|
||||||
|
}
|
||||||
|
if (!source) throw new Error(`不支持的 api: ${opts.api}`);
|
||||||
|
|
||||||
|
const model = String(opts.model || '').trim();
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
try { xbLog.error('streamingGeneration', 'missing model', null); } catch {}
|
||||||
|
}
|
||||||
|
if (!model) throw new Error('未检测到当前模型,请在聊天面板选择模型或在插件设置中为分析显式指定模型。');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (xbLog.isEnabled?.()) {
|
try {
|
||||||
const msgCount = Array.isArray(messages) ? messages.length : null;
|
if (xbLog.isEnabled?.()) {
|
||||||
xbLog.info('streamingGeneration', `callAPI stream=${!!stream} api=${String(opts.api || '')} model=${model} messages=${msgCount ?? '-'}`);
|
const msgCount = Array.isArray(messages) ? messages.length : null;
|
||||||
|
xbLog.info('streamingGeneration', `callAPI stream=${!!stream} api=${String(opts.api || '')} model=${model} messages=${msgCount ?? '-'}`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const provider = String(opts.api || '').toLowerCase();
|
||||||
|
const reverseProxyConfigured = String(opts.apiurl || '').trim().length > 0;
|
||||||
|
const pwd = String(opts.apipassword || '').trim();
|
||||||
|
if (!reverseProxyConfigured && pwd) {
|
||||||
|
const providerToSecretKey = {
|
||||||
|
openai: SECRET_KEYS.OPENAI,
|
||||||
|
gemini: SECRET_KEYS.MAKERSUITE,
|
||||||
|
google: SECRET_KEYS.MAKERSUITE,
|
||||||
|
cohere: SECRET_KEYS.COHERE,
|
||||||
|
deepseek: SECRET_KEYS.DEEPSEEK,
|
||||||
|
custom: SECRET_KEYS.CUSTOM,
|
||||||
|
};
|
||||||
|
const secretKey = providerToSecretKey[provider];
|
||||||
|
if (secretKey) {
|
||||||
|
await writeSecret(secretKey, pwd, 'xbgen-inline');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
const provider = String(opts.api || '').toLowerCase();
|
|
||||||
const reverseProxyConfigured = String(opts.apiurl || '').trim().length > 0;
|
const num = (v) => {
|
||||||
const pwd = String(opts.apipassword || '').trim();
|
const n = Number(v);
|
||||||
if (!reverseProxyConfigured && pwd) {
|
return Number.isFinite(n) ? n : undefined;
|
||||||
const providerToSecretKey = {
|
};
|
||||||
openai: SECRET_KEYS.OPENAI,
|
const isUnset = (k) => baseOptions?.[k] === '__unset__';
|
||||||
gemini: SECRET_KEYS.MAKERSUITE,
|
const tUser = num(baseOptions?.temperature);
|
||||||
google: SECRET_KEYS.MAKERSUITE,
|
const ppUser = num(baseOptions?.presence_penalty);
|
||||||
cohere: SECRET_KEYS.COHERE,
|
const fpUser = num(baseOptions?.frequency_penalty);
|
||||||
deepseek: SECRET_KEYS.DEEPSEEK,
|
const tpUser = num(baseOptions?.top_p);
|
||||||
custom: SECRET_KEYS.CUSTOM,
|
const tkUser = num(baseOptions?.top_k);
|
||||||
};
|
const mtUser = num(baseOptions?.max_tokens);
|
||||||
const secretKey = providerToSecretKey[provider];
|
const tUI = num(oai_settings?.temp_openai);
|
||||||
if (secretKey) {
|
const ppUI = num(oai_settings?.pres_pen_openai);
|
||||||
await writeSecret(secretKey, pwd, 'xbgen-inline');
|
const fpUI = num(oai_settings?.freq_pen_openai);
|
||||||
|
const tpUI_OpenAI = num(oai_settings?.top_p_openai ?? oai_settings?.top_p);
|
||||||
|
const mtUI_OpenAI = num(oai_settings?.openai_max_tokens ?? oai_settings?.max_tokens);
|
||||||
|
const tpUI_Gemini = num(oai_settings?.makersuite_top_p ?? oai_settings?.top_p);
|
||||||
|
const tkUI_Gemini = num(oai_settings?.makersuite_top_k ?? oai_settings?.top_k);
|
||||||
|
const mtUI_Gemini = num(oai_settings?.makersuite_max_tokens ?? oai_settings?.max_output_tokens ?? oai_settings?.openai_max_tokens ?? oai_settings?.max_tokens);
|
||||||
|
const effectiveTemperature = isUnset('temperature') ? undefined : (tUser ?? tUI);
|
||||||
|
const effectivePresence = isUnset('presence_penalty') ? undefined : (ppUser ?? ppUI);
|
||||||
|
const effectiveFrequency = isUnset('frequency_penalty') ? undefined : (fpUser ?? fpUI);
|
||||||
|
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 effectiveMaxT = isUnset('max_tokens') ? undefined : (mtUser ?? (source === chat_completion_sources.MAKERSUITE ? (mtUI_Gemini ?? mtUI_OpenAI) : mtUI_OpenAI) ?? 4000);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
messages, model, stream,
|
||||||
|
chat_completion_source: source,
|
||||||
|
temperature: effectiveTemperature,
|
||||||
|
presence_penalty: effectivePresence,
|
||||||
|
frequency_penalty: effectiveFrequency,
|
||||||
|
top_p: effectiveTopP,
|
||||||
|
max_tokens: effectiveMaxT,
|
||||||
|
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 (effectiveTopK !== undefined) body.top_k = effectiveTopK;
|
||||||
|
body.max_output_tokens = effectiveMaxT;
|
||||||
|
}
|
||||||
|
const useNet = !!opts.enableNet;
|
||||||
|
if (source === chat_completion_sources.MAKERSUITE && useNet) {
|
||||||
|
body.tools = Array.isArray(body.tools) ? body.tools : [];
|
||||||
|
if (!body.tools.some(t => t && t.google_search_retrieval)) {
|
||||||
|
body.tools.push({ google_search_retrieval: {} });
|
||||||
}
|
}
|
||||||
|
body.enable_web_search = true;
|
||||||
|
body.makersuite_use_google_search = true;
|
||||||
}
|
}
|
||||||
} catch {}
|
let reverseProxy = String(opts.apiurl || oai_settings?.reverse_proxy || '').trim();
|
||||||
const num = (v) => {
|
let proxyPassword = String(oai_settings?.proxy_password || '').trim();
|
||||||
const n = Number(v);
|
const cmdApiUrl = String(opts.apiurl || '').trim();
|
||||||
return Number.isFinite(n) ? n : undefined;
|
const cmdApiPwd = String(opts.apipassword || '').trim();
|
||||||
};
|
if (cmdApiUrl) {
|
||||||
const isUnset = (k) => baseOptions?.[k] === '__unset__';
|
if (cmdApiPwd) proxyPassword = cmdApiPwd;
|
||||||
const tUser = num(baseOptions?.temperature);
|
} else if (cmdApiPwd) {
|
||||||
const ppUser = num(baseOptions?.presence_penalty);
|
reverseProxy = '';
|
||||||
const fpUser = num(baseOptions?.frequency_penalty);
|
proxyPassword = '';
|
||||||
const tpUser = num(baseOptions?.top_p);
|
|
||||||
const tkUser = num(baseOptions?.top_k);
|
|
||||||
const mtUser = num(baseOptions?.max_tokens);
|
|
||||||
const tUI = num(oai_settings?.temp_openai);
|
|
||||||
const ppUI = num(oai_settings?.pres_pen_openai);
|
|
||||||
const fpUI = num(oai_settings?.freq_pen_openai);
|
|
||||||
const tpUI_OpenAI = num(oai_settings?.top_p_openai ?? oai_settings?.top_p);
|
|
||||||
const mtUI_OpenAI = num(oai_settings?.openai_max_tokens ?? oai_settings?.max_tokens);
|
|
||||||
const tpUI_Gemini = num(oai_settings?.makersuite_top_p ?? oai_settings?.top_p);
|
|
||||||
const tkUI_Gemini = num(oai_settings?.makersuite_top_k ?? oai_settings?.top_k);
|
|
||||||
const mtUI_Gemini = num(oai_settings?.makersuite_max_tokens ?? oai_settings?.max_output_tokens ?? oai_settings?.openai_max_tokens ?? oai_settings?.max_tokens);
|
|
||||||
const effectiveTemperature = isUnset('temperature') ? undefined : (tUser ?? tUI);
|
|
||||||
const effectivePresence = isUnset('presence_penalty') ? undefined : (ppUser ?? ppUI);
|
|
||||||
const effectiveFrequency = isUnset('frequency_penalty') ? undefined : (fpUser ?? fpUI);
|
|
||||||
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 effectiveMaxT = isUnset('max_tokens') ? undefined : (mtUser ?? (source === chat_completion_sources.MAKERSUITE ? (mtUI_Gemini ?? mtUI_OpenAI) : mtUI_OpenAI) ?? 4000);
|
|
||||||
const body = {
|
|
||||||
messages, model, stream,
|
|
||||||
chat_completion_source: source,
|
|
||||||
temperature: effectiveTemperature,
|
|
||||||
presence_penalty: effectivePresence,
|
|
||||||
frequency_penalty: effectiveFrequency,
|
|
||||||
top_p: effectiveTopP,
|
|
||||||
max_tokens: effectiveMaxT,
|
|
||||||
stop: Array.isArray(generateData?.stop) ? generateData.stop : undefined,
|
|
||||||
};
|
|
||||||
if (source === chat_completion_sources.MAKERSUITE) {
|
|
||||||
if (effectiveTopK !== undefined) body.top_k = effectiveTopK;
|
|
||||||
body.max_output_tokens = effectiveMaxT;
|
|
||||||
}
|
|
||||||
const useNet = !!opts.enableNet;
|
|
||||||
if (source === chat_completion_sources.MAKERSUITE && useNet) {
|
|
||||||
body.tools = Array.isArray(body.tools) ? body.tools : [];
|
|
||||||
if (!body.tools.some(t => t && t.google_search_retrieval)) {
|
|
||||||
body.tools.push({ google_search_retrieval: {} });
|
|
||||||
}
|
}
|
||||||
body.enable_web_search = true;
|
if (PROXY_SUPPORTED.has(source) && reverseProxy) {
|
||||||
body.makersuite_use_google_search = true;
|
body.reverse_proxy = reverseProxy.replace(/\/?$/, '');
|
||||||
}
|
if (proxyPassword) body.proxy_password = proxyPassword;
|
||||||
let reverseProxy = String(opts.apiurl || oai_settings?.reverse_proxy || '').trim();
|
|
||||||
let proxyPassword = String(oai_settings?.proxy_password || '').trim();
|
|
||||||
const cmdApiUrl = String(opts.apiurl || '').trim();
|
|
||||||
const cmdApiPwd = String(opts.apipassword || '').trim();
|
|
||||||
if (cmdApiUrl) {
|
|
||||||
if (cmdApiPwd) proxyPassword = cmdApiPwd;
|
|
||||||
} else if (cmdApiPwd) {
|
|
||||||
reverseProxy = '';
|
|
||||||
proxyPassword = '';
|
|
||||||
}
|
|
||||||
if (PROXY_SUPPORTED.has(source) && reverseProxy) {
|
|
||||||
body.reverse_proxy = reverseProxy.replace(/\/?$/, '');
|
|
||||||
if (proxyPassword) body.proxy_password = proxyPassword;
|
|
||||||
}
|
|
||||||
if (source === chat_completion_sources.CUSTOM) {
|
|
||||||
const customUrl = String(cmdApiUrl || oai_settings?.custom_url || '').trim();
|
|
||||||
if (customUrl) {
|
|
||||||
body.custom_url = customUrl;
|
|
||||||
} else {
|
|
||||||
throw new Error('未配置自定义后端URL,请在命令中提供 apiurl 或在设置中填写 custom_url');
|
|
||||||
}
|
}
|
||||||
if (oai_settings?.custom_include_headers) body.custom_include_headers = oai_settings.custom_include_headers;
|
if (source === chat_completion_sources.CUSTOM) {
|
||||||
if (oai_settings?.custom_include_body) body.custom_include_body = oai_settings.custom_include_body;
|
const customUrl = String(cmdApiUrl || oai_settings?.custom_url || '').trim();
|
||||||
if (oai_settings?.custom_exclude_body) body.custom_exclude_body = oai_settings.custom_exclude_body;
|
if (customUrl) {
|
||||||
}
|
body.custom_url = customUrl;
|
||||||
if (stream) {
|
} else {
|
||||||
// 流式:走 ChatCompletionService 统一链路
|
throw new Error('未配置自定义后端URL,请在命令中提供 apiurl 或在设置中填写 custom_url');
|
||||||
const payload = ChatCompletionService.createRequestData(body);
|
}
|
||||||
const streamFactory = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
if (oai_settings?.custom_include_headers) body.custom_include_headers = oai_settings.custom_include_headers;
|
||||||
const generator = (typeof streamFactory === 'function') ? streamFactory() : streamFactory;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyLog = { ...body, messages: `[${body.messages?.length || 0} messages]` };
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
const payload = ChatCompletionService.createRequestData(body);
|
||||||
|
|
||||||
|
const streamFactory = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
||||||
|
|
||||||
|
const generator = (typeof streamFactory === 'function') ? streamFactory() : streamFactory;
|
||||||
|
|
||||||
return (async function* () {
|
return (async function* () {
|
||||||
let last = '';
|
let last = '';
|
||||||
try {
|
let chunkCount = 0;
|
||||||
for await (const item of (generator || [])) {
|
try {
|
||||||
if (abortSignal?.aborted) return;
|
for await (const item of (generator || [])) {
|
||||||
|
chunkCount++;
|
||||||
|
if (abortSignal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let accumulated = '';
|
if (chunkCount <= 5 || chunkCount % 20 === 0) {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'object') {
|
||||||
accumulated = item;
|
}
|
||||||
} else if (item && typeof item === 'object') {
|
}
|
||||||
accumulated = (typeof item.text === 'string' ? item.text : '') ||
|
|
||||||
(typeof item.content === 'string' ? item.content : '') || '';
|
|
||||||
}
|
|
||||||
if (!accumulated && item && typeof item === 'object') {
|
|
||||||
const rc = item?.reasoning_content || item?.reasoning;
|
|
||||||
if (typeof rc === 'string') accumulated = rc;
|
|
||||||
}
|
|
||||||
if (!accumulated) continue;
|
|
||||||
|
|
||||||
if (accumulated.startsWith(last)) {
|
let accumulated = '';
|
||||||
last = accumulated;
|
if (typeof item === 'string') {
|
||||||
} else {
|
accumulated = item;
|
||||||
last += accumulated;
|
} else if (item && typeof item === 'object') {
|
||||||
|
// 尝试多种字段
|
||||||
|
accumulated = (typeof item.text === 'string' ? item.text : '') ||
|
||||||
|
(typeof item.content === 'string' ? item.content : '') || '';
|
||||||
|
|
||||||
|
// thinking 相关字段
|
||||||
|
if (!accumulated) {
|
||||||
|
const thinking = item?.delta?.thinking || item?.thinking;
|
||||||
|
if (typeof thinking === 'string') {
|
||||||
|
accumulated = thinking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!accumulated) {
|
||||||
|
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 (!accumulated) {
|
||||||
|
if (chunkCount <= 5) {
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accumulated.startsWith(last)) {
|
||||||
|
last = accumulated;
|
||||||
|
} else {
|
||||||
|
last += accumulated;
|
||||||
|
}
|
||||||
|
yield last;
|
||||||
}
|
}
|
||||||
yield last;
|
} 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;
|
||||||
|
try { xbLog.error('streamingGeneration', 'Stream error', err); } catch {}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
})();
|
||||||
if (err?.name === 'AbortError') return;
|
} else {
|
||||||
console.error('[StreamingGeneration] Stream error:', err);
|
const payload = ChatCompletionService.createRequestData(body);
|
||||||
try { xbLog.error('streamingGeneration', 'Stream error', err); } catch {}
|
const extracted = await ChatCompletionService.sendRequest(payload, false, abortSignal);
|
||||||
throw err;
|
|
||||||
|
let result = '';
|
||||||
|
if (extracted && typeof extracted === 'object') {
|
||||||
|
const msg = extracted?.choices?.[0]?.message;
|
||||||
|
result = String(
|
||||||
|
msg?.content ??
|
||||||
|
msg?.reasoning_content ??
|
||||||
|
extracted?.choices?.[0]?.text ??
|
||||||
|
extracted?.content ??
|
||||||
|
extracted?.reasoning_content ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = String(extracted ?? '');
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
} else {
|
return result;
|
||||||
// 非流式:extract=true,返回抽取后的结果
|
|
||||||
const payload = ChatCompletionService.createRequestData(body);
|
|
||||||
const extracted = await ChatCompletionService.sendRequest(payload, true, abortSignal);
|
|
||||||
|
|
||||||
let result = String((extracted && extracted.content) || '');
|
|
||||||
|
|
||||||
// reasoning_content 兜底
|
|
||||||
if (!result && extracted && typeof extracted === 'object') {
|
|
||||||
const rc = extracted?.reasoning_content || extracted?.reasoning;
|
|
||||||
if (typeof rc === 'string') result = rc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async _emitPromptReady(chatArray) {
|
async _emitPromptReady(chatArray) {
|
||||||
try {
|
try {
|
||||||
@@ -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 {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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