提示词调整
This commit is contained in:
@@ -5,9 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>皮下交流</title>
|
<title>皮下交流</title>
|
||||||
|
|
||||||
<!-- 样式保持不变,此处省略... -->
|
|
||||||
<style>
|
<style>
|
||||||
/* ... 所有样式保持原样 ... */
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -241,53 +239,63 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fw-voice-bubble {
|
.fw-voice-bubble {
|
||||||
display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
|
display: inline-flex;
|
||||||
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
|
align-items: center;
|
||||||
border-radius: 20px; cursor: pointer; user-select: none; transition: all 0.2s;
|
gap: 8px;
|
||||||
min-width: 100px; margin: 4px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
padding: 5px 10px;
|
||||||
|
background: #95ec69;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 4px 0;
|
||||||
|
transition: filter 0.15s;
|
||||||
}
|
}
|
||||||
.fw-bubble.user .fw-voice-bubble { background: linear-gradient(135deg, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0.15) 100%); }
|
.fw-voice-bubble:hover { filter: brightness(0.95); }
|
||||||
.fw-bubble.assistant .fw-voice-bubble { background: linear-gradient(135deg, #e0f7fa 0%, #e8f5e9 100%); }
|
.fw-voice-bubble:active { filter: brightness(0.9); }
|
||||||
.fw-voice-bubble:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
|
.fw-voice-waves {
|
||||||
.fw-voice-bubble:active { transform: scale(0.98); }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
.fw-voice-icon {
|
justify-content: flex-end;
|
||||||
display: flex; align-items: center; justify-content: center;
|
gap: 2px;
|
||||||
width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.6); flex-shrink: 0;
|
width: 20px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.fw-bubble.user .fw-voice-icon { background: rgba(255,255,255,0.3); }
|
.fw-voice-bar {
|
||||||
.fw-voice-icon i { font-size: 12px; color: #4c9aff; }
|
width: 3px;
|
||||||
.fw-bubble.user .fw-voice-icon i { color: #fff; }
|
background: #fff;
|
||||||
|
border-radius: 1.5px;
|
||||||
.fw-voice-waves { display: flex; align-items: center; gap: 3px; height: 20px; }
|
opacity: 0.9;
|
||||||
.fw-voice-bar { width: 3px; background: #4c9aff; border-radius: 2px; transition: height 0.1s; }
|
|
||||||
.fw-bubble.user .fw-voice-bar { background: rgba(255,255,255,0.9); }
|
|
||||||
.fw-voice-bar:nth-child(1) { height: 8px; }
|
|
||||||
.fw-voice-bar:nth-child(2) { height: 14px; }
|
|
||||||
.fw-voice-bar:nth-child(3) { height: 10px; }
|
|
||||||
.fw-voice-bar:nth-child(4) { height: 16px; }
|
|
||||||
.fw-voice-bar:nth-child(5) { height: 12px; }
|
|
||||||
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar { animation: voice-wave 0.6s infinite ease-in-out; }
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar:nth-child(1) { animation-delay: 0.0s; }
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar:nth-child(2) { animation-delay: 0.1s; }
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar:nth-child(3) { animation-delay: 0.2s; }
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar:nth-child(4) { animation-delay: 0.15s; }
|
|
||||||
.fw-voice-bubble.playing .fw-voice-bar:nth-child(5) { animation-delay: 0.25s; }
|
|
||||||
|
|
||||||
@keyframes voice-wave {
|
|
||||||
0%, 100% { height: 6px; opacity: 0.5; }
|
|
||||||
50% { height: 18px; opacity: 1; }
|
|
||||||
}
|
}
|
||||||
|
.fw-voice-bar:nth-child(1) { height: 6px; }
|
||||||
.fw-voice-duration { font-size: 0.75rem; font-weight: 600; color: #555; min-width: 24px; }
|
.fw-voice-bar:nth-child(2) { height: 10px; }
|
||||||
.fw-bubble.user .fw-voice-duration { color: rgba(255,255,255,0.9); }
|
.fw-voice-bar:nth-child(3) { height: 14px; }
|
||||||
.fw-voice-emotion-tag { font-size: 12px; margin-left: 4px; opacity: 0.8; }
|
.fw-voice-bubble.playing .fw-voice-bar {
|
||||||
.fw-voice-bubble.loading .fw-voice-waves { display: none; }
|
animation: fw-wechat-wave 1.2s infinite ease-in-out;
|
||||||
.fw-voice-bubble.loading .fw-voice-icon i::before { content: "\f110"; animation: fa-spin 1s infinite linear; }
|
}
|
||||||
.fw-voice-bubble.error { background: linear-gradient(135deg, #ffeaea 0%, #ffdbdb 100%) !important; }
|
.fw-voice-bubble.playing .fw-voice-bar:nth-child(1) { animation-delay: 0s; }
|
||||||
.fw-voice-bubble.error .fw-voice-icon { background: rgba(239,68,68,0.2); }
|
.fw-voice-bubble.playing .fw-voice-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||||
.fw-voice-bubble.error .fw-voice-icon i { color: #ef4444; }
|
.fw-voice-bubble.playing .fw-voice-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes fw-wechat-wave {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.fw-voice-duration {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #000;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.fw-voice-bubble.loading { opacity: 0.7; }
|
||||||
|
.fw-voice-bubble.loading .fw-voice-waves { animation: fw-voice-pulse 1s infinite; }
|
||||||
|
@keyframes fw-voice-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
||||||
|
.fw-voice-bubble.error { background: #ffb3b3 !important; }
|
||||||
|
/* 用户消息中的语音(白色背景) */
|
||||||
|
.fw-row.user .fw-voice-bubble { background: #fff; }
|
||||||
|
.fw-row.user .fw-voice-bar { background: #b2b2b2; }
|
||||||
|
.fw-row.user .fw-voice-duration { color: #555; }
|
||||||
|
|
||||||
.fw-input-area { padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); flex-shrink: 0; }
|
.fw-input-area { padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); flex-shrink: 0; }
|
||||||
.fw-input-row { display: flex; gap: 10px; align-items: flex-end; }
|
.fw-input-row { display: flex; gap: 10px; align-items: flex-end; }
|
||||||
@@ -730,6 +738,25 @@ function handleCacheMiss(data) {
|
|||||||
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中...</div>`;
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中...</div>`;
|
||||||
postToParent({ type: 'GENERATE_IMAGE', requestId: data.requestId, tags });
|
postToParent({ type: 'GENERATE_IMAGE', requestId: data.requestId, tags });
|
||||||
}
|
}
|
||||||
|
function handleImageProgress(data) {
|
||||||
|
const pending = pendingImages.get(data.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
const { slot } = pending;
|
||||||
|
if (!slot) return;
|
||||||
|
|
||||||
|
switch (data.status) {
|
||||||
|
case 'queued':
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${data.position}</div>`;
|
||||||
|
break;
|
||||||
|
case 'generating':
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中${data.position > 0 ? ` (${data.position} 排队)` : ''}...</div>`;
|
||||||
|
break;
|
||||||
|
case 'waiting':
|
||||||
|
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${data.position} (${data.delay}s)</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function bindRetryButton(slot) {
|
function bindRetryButton(slot) {
|
||||||
const btn = slot.querySelector('.fw-img-retry');
|
const btn = slot.querySelector('.fw-img-retry');
|
||||||
@@ -824,7 +851,7 @@ function hydrateVoiceSlots(container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════════════════════════
|
||||||
内容渲染(保持不变)
|
内容渲染
|
||||||
══════════════════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
function renderContent(text) {
|
function renderContent(text) {
|
||||||
@@ -841,24 +868,28 @@ function renderContent(text) {
|
|||||||
const emotion = (emotionRaw || '').trim().toLowerCase();
|
const emotion = (emotionRaw || '').trim().toLowerCase();
|
||||||
const txt = voiceText.trim();
|
const txt = voiceText.trim();
|
||||||
if (!txt) return _;
|
if (!txt) return _;
|
||||||
const duration = Math.max(1, Math.ceil(txt.length / 4)) + '"';
|
const duration = Math.max(2, Math.ceil(txt.length / 4));
|
||||||
const emotionIcon = getEmotionIcon(emotion);
|
|
||||||
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="${emotion}">
|
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="${emotion}">
|
||||||
<div class="fw-voice-icon"><i class="fa-solid fa-microphone"></i></div>
|
<div class="fw-voice-waves">
|
||||||
<div class="fw-voice-waves"><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div></div>
|
<div class="fw-voice-bar"></div>
|
||||||
<span class="fw-voice-duration">${duration}</span>
|
<div class="fw-voice-bar"></div>
|
||||||
${emotionIcon ? `<span class="fw-voice-emotion-tag">${emotionIcon}</span>` : ''}
|
<div class="fw-voice-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fw-voice-duration">${duration}"</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, voiceText) => {
|
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, voiceText) => {
|
||||||
const txt = voiceText.trim();
|
const txt = voiceText.trim();
|
||||||
if (!txt) return _;
|
if (!txt) return _;
|
||||||
const duration = Math.max(1, Math.ceil(txt.length / 4)) + '"';
|
const duration = Math.max(2, Math.ceil(txt.length / 4));
|
||||||
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="">
|
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="">
|
||||||
<div class="fw-voice-icon"><i class="fa-solid fa-microphone"></i></div>
|
<div class="fw-voice-waves">
|
||||||
<div class="fw-voice-waves"><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div><div class="fw-voice-bar"></div></div>
|
<div class="fw-voice-bar"></div>
|
||||||
<span class="fw-voice-duration">${duration}</span>
|
<div class="fw-voice-bar"></div>
|
||||||
|
<div class="fw-voice-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fw-voice-duration">${duration}"</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1176,6 +1207,9 @@ window.addEventListener('message', event => {
|
|||||||
case 'CACHE_MISS':
|
case 'CACHE_MISS':
|
||||||
handleCacheMiss(data);
|
handleCacheMiss(data);
|
||||||
break;
|
break;
|
||||||
|
case 'IMAGE_PROGRESS':
|
||||||
|
handleImageProgress(data);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
DEFAULT_BOTTOM,
|
DEFAULT_BOTTOM,
|
||||||
DEFAULT_META_PROTOCOL
|
DEFAULT_META_PROTOCOL
|
||||||
} from "./fw-prompt.js";
|
} from "./fw-prompt.js";
|
||||||
|
import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js";
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// 常量
|
// 常量
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -967,6 +967,7 @@ function initFourthWall() {
|
|||||||
createFloatingButton();
|
createFloatingButton();
|
||||||
initCommentary();
|
initCommentary();
|
||||||
clearExpiredCache();
|
clearExpiredCache();
|
||||||
|
initMessageEnhancer();
|
||||||
|
|
||||||
events.on(event_types.CHAT_CHANGED, () => {
|
events.on(event_types.CHAT_CHANGED, () => {
|
||||||
cancelGeneration();
|
cancelGeneration();
|
||||||
@@ -983,6 +984,7 @@ function fourthWallCleanup() {
|
|||||||
removeFloatingButton();
|
removeFloatingButton();
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
cancelGeneration();
|
cancelGeneration();
|
||||||
|
cleanupMessageEnhancer();
|
||||||
frameReady = false;
|
frameReady = false;
|
||||||
pendingFrameMessages = [];
|
pendingFrameMessages = [];
|
||||||
overlayCreated = false;
|
overlayCreated = false;
|
||||||
|
|||||||
@@ -1,28 +1,99 @@
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// 图片模块 - 缓存与生成
|
// 图片模块 - 缓存与生成(带队列)
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const DB_NAME = 'xb_fourth_wall_images';
|
const DB_NAME = 'xb_fourth_wall_images';
|
||||||
const DB_STORE = 'images';
|
const DB_STORE = 'images';
|
||||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 队列配置
|
||||||
|
const QUEUE_DELAY_MIN = 5000;
|
||||||
|
const QUEUE_DELAY_MAX = 10000;
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
* 图片提示词指南 - 注入给 LLM
|
// 生成队列(全局共享)
|
||||||
*/
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
export const IMG_GUIDELINE = `## 模拟图片
|
|
||||||
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
|
|
||||||
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
|
|
||||||
- tag必须为英文,用逗号分隔,使用Danbooru风格的tag,5-15个tag
|
|
||||||
- 第一个tag须固定为人物数量标签,如: 1girl, 1boy, 2girls, solo, etc.
|
|
||||||
- 可以多张照片: 每行一张 [image: ...]
|
|
||||||
- 当需要发送的内容尺度较大时加上nsfw相关tag
|
|
||||||
- image部分也需要在<msg>内`;
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
const generateQueue = [];
|
||||||
// IndexedDB 操作
|
let isQueueProcessing = false;
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
function getRandomDelay() {
|
||||||
|
return QUEUE_DELAY_MIN + Math.random() * (QUEUE_DELAY_MAX - QUEUE_DELAY_MIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将生成任务加入队列
|
||||||
|
* @returns {Promise<string>} base64 图片
|
||||||
|
*/
|
||||||
|
function enqueueGeneration(tags, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const position = generateQueue.length + 1;
|
||||||
|
onProgress?.('queued', position);
|
||||||
|
|
||||||
|
generateQueue.push({ tags, resolve, reject, onProgress });
|
||||||
|
processQueue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processQueue() {
|
||||||
|
if (isQueueProcessing || generateQueue.length === 0) return;
|
||||||
|
|
||||||
|
isQueueProcessing = true;
|
||||||
|
|
||||||
|
while (generateQueue.length > 0) {
|
||||||
|
const { tags, resolve, reject, onProgress } = generateQueue.shift();
|
||||||
|
|
||||||
|
// 通知:开始生成
|
||||||
|
onProgress?.('generating', generateQueue.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = await doGenerateImage(tags);
|
||||||
|
resolve(base64);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还有待处理的,等待冷却
|
||||||
|
if (generateQueue.length > 0) {
|
||||||
|
const delay = getRandomDelay();
|
||||||
|
|
||||||
|
// 通知所有排队中的任务
|
||||||
|
generateQueue.forEach((item, idx) => {
|
||||||
|
item.onProgress?.('waiting', idx + 1, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isQueueProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取队列状态
|
||||||
|
*/
|
||||||
|
export function getQueueStatus() {
|
||||||
|
return {
|
||||||
|
pending: generateQueue.length,
|
||||||
|
isProcessing: isQueueProcessing
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空队列
|
||||||
|
*/
|
||||||
|
export function clearQueue() {
|
||||||
|
while (generateQueue.length > 0) {
|
||||||
|
const { reject } = generateQueue.shift();
|
||||||
|
reject(new Error('队列已清空'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// IndexedDB 操作(保持不变)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
async function openDB() {
|
async function openDB() {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
@@ -78,9 +149,6 @@ async function saveToCache(tags, base64) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理过期缓存
|
|
||||||
*/
|
|
||||||
export async function clearExpiredCache() {
|
export async function clearExpiredCache() {
|
||||||
try {
|
try {
|
||||||
const database = await openDB();
|
const database = await openDB();
|
||||||
@@ -97,15 +165,67 @@ export async function clearExpiredCache() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// 图片请求处理
|
// 图片生成(内部函数,直接调用 NovelDraw)
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function doGenerateImage(tags) {
|
||||||
|
const novelDraw = window.xiaobaixNovelDraw;
|
||||||
|
if (!novelDraw) {
|
||||||
|
throw new Error('NovelDraw 模块未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = novelDraw.getSettings();
|
||||||
|
const paramsPreset = settings.paramsPresets?.find(p => p.id === settings.selectedParamsPresetId)
|
||||||
|
|| settings.paramsPresets?.[0];
|
||||||
|
|
||||||
|
if (!paramsPreset) {
|
||||||
|
throw new Error('无可用的参数预设');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scene = [paramsPreset.positivePrefix, tags].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
const base64 = await novelDraw.generateNovelImage({
|
||||||
|
scene,
|
||||||
|
characterPrompts: [],
|
||||||
|
negativePrompt: paramsPreset.negativePrefix || '',
|
||||||
|
params: paramsPreset.params || {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveToCache(tags, base64);
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 公开接口
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理缓存检查请求
|
* 检查缓存
|
||||||
* @param {Object} data - { requestId, tags }
|
|
||||||
* @param {Function} postToFrame - 发送消息到 iframe 的函数
|
|
||||||
*/
|
*/
|
||||||
|
export async function checkImageCache(tags) {
|
||||||
|
return await getFromCache(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图片(自动排队)
|
||||||
|
* @param {string} tags - 图片标签
|
||||||
|
* @param {Function} [onProgress] - 进度回调 (status, position, delay?)
|
||||||
|
* @returns {Promise<string>} base64 图片
|
||||||
|
*/
|
||||||
|
export async function generateImage(tags, onProgress) {
|
||||||
|
// 先检查缓存
|
||||||
|
const cached = await getFromCache(tags);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// 加入队列生成
|
||||||
|
return enqueueGeneration(tags, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// postMessage 接口(用于 iframe)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export async function handleCheckCache(data, postToFrame) {
|
export async function handleCheckCache(data, postToFrame) {
|
||||||
const { requestId, tags } = data;
|
const { requestId, tags } = data;
|
||||||
|
|
||||||
@@ -123,11 +243,6 @@ export async function handleCheckCache(data, postToFrame) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理图片生成请求
|
|
||||||
* @param {Object} data - { requestId, tags }
|
|
||||||
* @param {Function} postToFrame - 发送消息到 iframe 的函数
|
|
||||||
*/
|
|
||||||
export async function handleGenerate(data, postToFrame) {
|
export async function handleGenerate(data, postToFrame) {
|
||||||
const { requestId, tags } = data;
|
const { requestId, tags } = data;
|
||||||
|
|
||||||
@@ -136,35 +251,30 @@ export async function handleGenerate(data, postToFrame) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const novelDraw = window.xiaobaixNovelDraw;
|
|
||||||
if (!novelDraw) {
|
|
||||||
postToFrame({ type: 'IMAGE_RESULT', requestId, error: 'NovelDraw 模块未启用' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = novelDraw.getSettings();
|
// 使用队列生成,发送进度更新
|
||||||
const paramsPreset = settings.paramsPresets?.find(p => p.id === settings.selectedParamsPresetId)
|
const base64 = await generateImage(tags, (status, position, delay) => {
|
||||||
|| settings.paramsPresets?.[0];
|
postToFrame({
|
||||||
|
type: 'IMAGE_PROGRESS',
|
||||||
if (!paramsPreset) {
|
requestId,
|
||||||
postToFrame({ type: 'IMAGE_RESULT', requestId, error: '无可用的参数预设' });
|
status,
|
||||||
return;
|
position,
|
||||||
}
|
delay: delay ? Math.round(delay / 1000) : undefined
|
||||||
|
});
|
||||||
const scene = [paramsPreset.positivePrefix, tags].filter(Boolean).join(', ');
|
|
||||||
|
|
||||||
const base64 = await novelDraw.generateNovelImage({
|
|
||||||
scene,
|
|
||||||
characterPrompts: [],
|
|
||||||
negativePrompt: paramsPreset.negativePrefix || '',
|
|
||||||
params: paramsPreset.params || {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveToCache(tags, base64);
|
|
||||||
postToFrame({ type: 'IMAGE_RESULT', requestId, base64 });
|
postToFrame({ type: 'IMAGE_RESULT', requestId, base64 });
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
postToFrame({ type: 'IMAGE_RESULT', requestId, error: e?.message || '生成失败' });
|
postToFrame({ type: 'IMAGE_RESULT', requestId, error: e?.message || '生成失败' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const IMG_GUIDELINE = `## 模拟图片
|
||||||
|
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
|
||||||
|
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
|
||||||
|
- tag必须为英文,用逗号分隔,使用Danbooru风格的tag,5-15个tag
|
||||||
|
- 第一个tag须固定为人物数量标签,如: 1girl, 1boy, 2girls, solo, etc.
|
||||||
|
- 可以多张照片: 每行一张 [image: ...]
|
||||||
|
- 当需要发送的内容尺度较大时加上nsfw相关tag
|
||||||
|
- image部分也需要在<msg>内`;
|
||||||
|
|||||||
478
modules/fourth-wall/fw-message-enhancer.js
Normal file
478
modules/fourth-wall/fw-message-enhancer.js
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 消息楼层增强器
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { extension_settings } from "../../../../../extensions.js";
|
||||||
|
import { EXT_ID } from "../../core/constants.js";
|
||||||
|
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||||
|
import { xbLog } from "../../core/debug-core.js";
|
||||||
|
|
||||||
|
import { generateImage, clearQueue } from "./fw-image.js";
|
||||||
|
import {
|
||||||
|
synthesizeSpeech,
|
||||||
|
loadVoices,
|
||||||
|
VALID_EMOTIONS,
|
||||||
|
DEFAULT_VOICE,
|
||||||
|
DEFAULT_SPEED
|
||||||
|
} from "./fw-voice.js";
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 状态
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const events = createModuleEvents('messageEnhancer');
|
||||||
|
const CSS_INJECTED_KEY = 'xb-me-css-injected';
|
||||||
|
|
||||||
|
let currentAudio = null;
|
||||||
|
let imageObserver = null;
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 初始化与清理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export async function initMessageEnhancer() {
|
||||||
|
const settings = extension_settings[EXT_ID];
|
||||||
|
if (!settings?.fourthWall?.enabled) return;
|
||||||
|
|
||||||
|
xbLog.info('messageEnhancer', '初始化消息增强器');
|
||||||
|
|
||||||
|
injectStyles();
|
||||||
|
await loadVoices();
|
||||||
|
initImageObserver();
|
||||||
|
|
||||||
|
events.on(event_types.CHAT_CHANGED, () => {
|
||||||
|
clearQueue();
|
||||||
|
setTimeout(processAllMessages, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
events.on(event_types.MESSAGE_EDITED, handleMessageChange);
|
||||||
|
events.on(event_types.MESSAGE_SWIPED, handleMessageChange);
|
||||||
|
events.on(event_types.MESSAGE_UPDATED, handleMessageChange);
|
||||||
|
events.on(event_types.CHARACTER_MESSAGE_RENDERED, handleMessageChange);
|
||||||
|
events.on(event_types.USER_MESSAGE_RENDERED, handleMessageChange);
|
||||||
|
|
||||||
|
processAllMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupMessageEnhancer() {
|
||||||
|
xbLog.info('messageEnhancer', '清理消息增强器');
|
||||||
|
|
||||||
|
events.cleanup();
|
||||||
|
clearQueue();
|
||||||
|
|
||||||
|
if (imageObserver) {
|
||||||
|
imageObserver.disconnect();
|
||||||
|
imageObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 事件处理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function handleMessageChange(data) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const messageId = typeof data === 'object'
|
||||||
|
? (data.messageId ?? data.id ?? data.index ?? data.mesId)
|
||||||
|
: data;
|
||||||
|
|
||||||
|
if (Number.isFinite(messageId)) {
|
||||||
|
const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`);
|
||||||
|
if (mesText) {
|
||||||
|
enhanceMessageContent(mesText);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
processAllMessages();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processAllMessages() {
|
||||||
|
const settings = extension_settings[EXT_ID];
|
||||||
|
if (!settings?.fourthWall?.enabled) return;
|
||||||
|
document.querySelectorAll('#chat .mes .mes_text').forEach(enhanceMessageContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 图片观察器
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function initImageObserver() {
|
||||||
|
if (imageObserver) return;
|
||||||
|
|
||||||
|
imageObserver = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
const slot = entry.target;
|
||||||
|
if (slot.dataset.loaded === '1' || slot.dataset.loading === '1') return;
|
||||||
|
const tags = decodeURIComponent(slot.dataset.tags || '');
|
||||||
|
if (!tags) return;
|
||||||
|
slot.dataset.loading = '1';
|
||||||
|
loadImage(slot, tags);
|
||||||
|
});
|
||||||
|
}, { rootMargin: '200px 0px', threshold: 0.01 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 样式
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById(CSS_INJECTED_KEY)) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = CSS_INJECTED_KEY;
|
||||||
|
style.textContent = `
|
||||||
|
.xb-voice-bubble {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #95ec69;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
min-width: 60px;
|
||||||
|
max-width: 180px;
|
||||||
|
margin: 3px 0;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.xb-voice-bubble:hover { filter: brightness(0.95); }
|
||||||
|
.xb-voice-bubble:active { filter: brightness(0.9); }
|
||||||
|
.xb-voice-waves {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
width: 16px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.xb-voice-bar {
|
||||||
|
width: 2px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 1px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.xb-voice-bar:nth-child(1) { height: 5px; }
|
||||||
|
.xb-voice-bar:nth-child(2) { height: 8px; }
|
||||||
|
.xb-voice-bar:nth-child(3) { height: 11px; }
|
||||||
|
.xb-voice-bubble.playing .xb-voice-bar {
|
||||||
|
animation: xb-wechat-wave 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(1) { animation-delay: 0s; }
|
||||||
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes xb-wechat-wave {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.xb-voice-duration {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #000;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.xb-voice-bubble.loading { opacity: 0.7; }
|
||||||
|
.xb-voice-bubble.loading .xb-voice-waves { animation: xb-voice-pulse 1s infinite; }
|
||||||
|
@keyframes xb-voice-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
||||||
|
.xb-voice-bubble.error { background: #ffb3b3 !important; }
|
||||||
|
.mes[is_user="true"] .xb-voice-bubble { background: #fff; }
|
||||||
|
.mes[is_user="true"] .xb-voice-bar { background: #b2b2b2; }
|
||||||
|
.xb-img-slot {
|
||||||
|
margin: 8px 0;
|
||||||
|
min-height: 60px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.xb-img-slot img.xb-generated-img {
|
||||||
|
max-width: min(400px, 80%);
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.xb-img-slot img.xb-generated-img:hover { opacity: 0.9; }
|
||||||
|
.xb-img-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
border: 1px dashed rgba(0,0,0,0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.xb-img-placeholder i { font-size: 16px; opacity: 0.5; }
|
||||||
|
.xb-img-loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(76,154,255,0.08);
|
||||||
|
border: 1px solid rgba(76,154,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.xb-img-loading i { animation: fa-spin 1s infinite linear; }
|
||||||
|
.xb-img-loading i.fa-clock { animation: none; }
|
||||||
|
.xb-img-error {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(255,100,100,0.08);
|
||||||
|
border: 1px dashed rgba(255,100,100,0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #e57373;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.xb-img-retry {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255,100,100,0.1);
|
||||||
|
border: 1px solid rgba(255,100,100,0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #e57373;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.xb-img-retry:hover { background: rgba(255,100,100,0.2); }
|
||||||
|
.xb-img-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
color: #ffd700;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 内容增强
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function enhanceMessageContent(container) {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const html = container.innerHTML;
|
||||||
|
let enhanced = html;
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
enhanced = enhanced.replace(/\[(?:image|图片)\s*:\s*([^\]]+)\]/gi, (match, inner) => {
|
||||||
|
const tags = parseImageToken(inner);
|
||||||
|
if (!tags) return match;
|
||||||
|
hasChanges = true;
|
||||||
|
return `<div class="xb-img-slot" data-tags="${encodeURIComponent(tags)}"></div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (match, emotionRaw, voiceText) => {
|
||||||
|
const txt = voiceText.trim();
|
||||||
|
if (!txt) return match;
|
||||||
|
hasChanges = true;
|
||||||
|
return createVoiceBubbleHTML(txt, (emotionRaw || '').trim().toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:\s*([^\]:]+)\]/gi, (match, voiceText) => {
|
||||||
|
const txt = voiceText.trim();
|
||||||
|
if (!txt) return match;
|
||||||
|
hasChanges = true;
|
||||||
|
return createVoiceBubbleHTML(txt, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
container.innerHTML = enhanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateImageSlots(container);
|
||||||
|
hydrateVoiceSlots(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImageToken(rawCSV) {
|
||||||
|
let txt = String(rawCSV || '').trim();
|
||||||
|
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, 'nsfw, ');
|
||||||
|
return txt.split(',').map(s => s.trim()).filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVoiceBubbleHTML(text, emotion) {
|
||||||
|
const duration = Math.max(2, Math.ceil(text.length / 4));
|
||||||
|
return `<div class="xb-voice-bubble" data-text="${encodeURIComponent(text)}" data-emotion="${emotion || ''}">
|
||||||
|
<div class="xb-voice-waves">
|
||||||
|
<div class="xb-voice-bar"></div>
|
||||||
|
<div class="xb-voice-bar"></div>
|
||||||
|
<div class="xb-voice-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span class="xb-voice-duration">${duration}"</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 图片处理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function hydrateImageSlots(container) {
|
||||||
|
container.querySelectorAll('.xb-img-slot').forEach(slot => {
|
||||||
|
if (slot.dataset.observed === '1') return;
|
||||||
|
slot.dataset.observed = '1';
|
||||||
|
|
||||||
|
if (!slot.dataset.loaded && !slot.dataset.loading && !slot.querySelector('img')) {
|
||||||
|
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageObserver?.observe(slot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImage(slot, tags) {
|
||||||
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-spinner"></i> 检查缓存...</div>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base64 = await generateImage(tags, (status, position, delay) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'queued':
|
||||||
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position}</div>`;
|
||||||
|
break;
|
||||||
|
case 'generating':
|
||||||
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-palette"></i> 生成中${position > 0 ? ` (${position} 排队)` : ''}...</div>`;
|
||||||
|
break;
|
||||||
|
case 'waiting':
|
||||||
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position} (${delay}s)</div>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (base64) {
|
||||||
|
renderImage(slot, base64, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
slot.dataset.loaded = '1';
|
||||||
|
slot.dataset.loading = '';
|
||||||
|
|
||||||
|
if (err.message === '队列已清空') {
|
||||||
|
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
||||||
|
slot.dataset.loading = '';
|
||||||
|
slot.dataset.observed = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
slot.innerHTML = `<div class="xb-img-error">
|
||||||
|
<i class="fa-solid fa-exclamation-triangle"></i>
|
||||||
|
<div>${escapeHtml(err?.message || '失败')}</div>
|
||||||
|
<button class="xb-img-retry" data-tags="${encodeURIComponent(tags)}">重试</button>
|
||||||
|
</div>`;
|
||||||
|
bindRetryButton(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage(slot, base64, fromCache) {
|
||||||
|
slot.dataset.loaded = '1';
|
||||||
|
slot.dataset.loading = '';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = `data:image/png;base64,${base64}`;
|
||||||
|
img.className = 'xb-generated-img';
|
||||||
|
img.onclick = () => window.open(img.src, '_blank');
|
||||||
|
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.appendChild(img);
|
||||||
|
|
||||||
|
if (fromCache) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'xb-img-badge';
|
||||||
|
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
|
||||||
|
slot.appendChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRetryButton(slot) {
|
||||||
|
const btn = slot.querySelector('.xb-img-retry');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.onclick = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const tags = decodeURIComponent(btn.dataset.tags || '');
|
||||||
|
if (!tags) return;
|
||||||
|
slot.dataset.loaded = '';
|
||||||
|
slot.dataset.loading = '1';
|
||||||
|
await loadImage(slot, tags);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 语音处理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function hydrateVoiceSlots(container) {
|
||||||
|
container.querySelectorAll('.xb-voice-bubble').forEach(bubble => {
|
||||||
|
if (bubble.dataset.bound === '1') return;
|
||||||
|
bubble.dataset.bound = '1';
|
||||||
|
|
||||||
|
const text = decodeURIComponent(bubble.dataset.text || '');
|
||||||
|
const emotion = bubble.dataset.emotion || '';
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
bubble.onclick = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (bubble.classList.contains('loading')) return;
|
||||||
|
|
||||||
|
if (bubble.classList.contains('playing') && currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio = null;
|
||||||
|
bubble.classList.remove('playing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentAudio) {
|
||||||
|
currentAudio.pause();
|
||||||
|
currentAudio = null;
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.xb-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
|
||||||
|
|
||||||
|
await playVoice(text, emotion, bubble);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playVoice(text, emotion, bubbleEl) {
|
||||||
|
bubbleEl.classList.add('loading');
|
||||||
|
bubbleEl.classList.remove('error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = extension_settings[EXT_ID]?.fourthWallVoice || {};
|
||||||
|
const audioBase64 = await synthesizeSpeech(text, {
|
||||||
|
voiceKey: settings.voice || DEFAULT_VOICE,
|
||||||
|
speed: settings.speed || DEFAULT_SPEED,
|
||||||
|
emotion: VALID_EMOTIONS.includes(emotion) ? emotion : null
|
||||||
|
});
|
||||||
|
|
||||||
|
bubbleEl.classList.remove('loading');
|
||||||
|
bubbleEl.classList.add('playing');
|
||||||
|
|
||||||
|
currentAudio = new Audio(`data:audio/mp3;base64,${audioBase64}`);
|
||||||
|
currentAudio.onended = () => { bubbleEl.classList.remove('playing'); currentAudio = null; };
|
||||||
|
currentAudio.onerror = () => { bubbleEl.classList.remove('playing'); bubbleEl.classList.add('error'); currentAudio = null; };
|
||||||
|
await currentAudio.play();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MessageEnhancer] TTS 错误:', err);
|
||||||
|
bubbleEl.classList.remove('loading', 'playing');
|
||||||
|
bubbleEl.classList.add('error');
|
||||||
|
setTimeout(() => bubbleEl.classList.remove('error'), 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,107 @@
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// 语音模块
|
// 语音模块 - TTS 合成服务
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const TTS_WORKER_URL = 'https://hstts.velure.top';
|
||||||
export const DEFAULT_VOICE = 'female_1';
|
export const DEFAULT_VOICE = 'female_1';
|
||||||
export const DEFAULT_SPEED = 1.0;
|
export const DEFAULT_SPEED = 1.0;
|
||||||
|
|
||||||
|
export const VALID_EMOTIONS = ['happy', 'sad', 'angry', 'surprise', 'scare', 'hate'];
|
||||||
|
export const EMOTION_ICONS = {
|
||||||
|
happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢'
|
||||||
|
};
|
||||||
|
|
||||||
|
let voiceListCache = null;
|
||||||
|
let defaultVoiceKey = DEFAULT_VOICE;
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 声音列表管理
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载可用声音列表
|
||||||
|
*/
|
||||||
|
export async function loadVoices() {
|
||||||
|
if (voiceListCache) return { voices: voiceListCache, defaultVoice: defaultVoiceKey };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${TTS_WORKER_URL}/voices`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
voiceListCache = data.voices || [];
|
||||||
|
defaultVoiceKey = data.defaultVoice || DEFAULT_VOICE;
|
||||||
|
return { voices: voiceListCache, defaultVoice: defaultVoiceKey };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[FW Voice] 加载声音列表失败:', err);
|
||||||
|
return { voices: [], defaultVoice: DEFAULT_VOICE };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已缓存的声音列表
|
||||||
|
*/
|
||||||
|
export function getVoiceList() {
|
||||||
|
return voiceListCache || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认声音
|
||||||
|
*/
|
||||||
|
export function getDefaultVoice() {
|
||||||
|
return defaultVoiceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// TTS 合成
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合成语音
|
||||||
|
* @param {string} text - 要合成的文本
|
||||||
|
* @param {Object} options - 选项
|
||||||
|
* @param {string} [options.voiceKey] - 声音标识
|
||||||
|
* @param {number} [options.speed] - 语速 0.5-2.0
|
||||||
|
* @param {string} [options.emotion] - 情绪
|
||||||
|
* @returns {Promise<string>} base64 编码的音频数据
|
||||||
|
*/
|
||||||
|
export async function synthesizeSpeech(text, options = {}) {
|
||||||
|
const {
|
||||||
|
voiceKey = defaultVoiceKey,
|
||||||
|
speed = DEFAULT_SPEED,
|
||||||
|
emotion = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
voiceKey,
|
||||||
|
text: String(text || ''),
|
||||||
|
speed: Number(speed) || DEFAULT_SPEED,
|
||||||
|
uid: 'xb_' + Date.now(),
|
||||||
|
reqid: crypto.randomUUID?.() || `${Date.now()}_${Math.random().toString(36).slice(2)}`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (emotion && VALID_EMOTIONS.includes(emotion)) {
|
||||||
|
requestBody.emotion = emotion;
|
||||||
|
requestBody.emotionScale = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(TTS_WORKER_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`TTS HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.code !== 3000) throw new Error(data.message || 'TTS 合成失败');
|
||||||
|
|
||||||
|
return data.data; // base64 音频
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 提示词指南
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export const VOICE_GUIDELINE = `## 模拟语音
|
export const VOICE_GUIDELINE = `## 模拟语音
|
||||||
如需发送语音消息,使用以下格式:
|
如需发送语音消息,使用以下格式:
|
||||||
[voice:情绪:语音内容]
|
[voice:情绪:语音内容]
|
||||||
@@ -23,13 +120,11 @@ export const VOICE_GUIDELINE = `## 模拟语音
|
|||||||
- !感叹号:语气有力
|
- !感叹号:语气有力
|
||||||
- ?问号:疑问上扬
|
- ?问号:疑问上扬
|
||||||
- ~波浪号:撒娇拖音
|
- ~波浪号:撒娇拖音
|
||||||
|
- —— 拉长、强调、戏剧化
|
||||||
### 示例:
|
### 示例:
|
||||||
[voice:happy:太好了!终于见到你了~]
|
[voice:happy:太好了!终于见到你了~]
|
||||||
[voice:sad:我……我没事的……]
|
[voice:sad:我……我没事的……]
|
||||||
[voice:angry:你怎么能这样!]
|
[voice:angry:你怎么能这样!]
|
||||||
[voice:scare:那、那是什么……?]
|
[voice::——啊!——不要!]
|
||||||
[voice:hate:这东西也太恶心了吧……]
|
|
||||||
[voice::嗯,我知道了。]
|
|
||||||
|
|
||||||
注意:voice部分需要在<msg>内`;
|
注意:voice部分需要在<msg>内`;
|
||||||
|
|||||||
@@ -63,11 +63,19 @@ CHARS:
|
|||||||
---
|
---
|
||||||
Now review the【TAG编写指南】. Confirm upon receipt.`,
|
Now review the【TAG编写指南】. Confirm upon receipt.`,
|
||||||
assistantAck: '明白。锚点:原文复制,5-15字,句末标点。格式:[IMG:n|锚点]+SCENE+CHARS。NSFW场景必须加nsfw标签并用解剖学词汇。互动标签必须成对。已知角色只输出action。',
|
assistantAck: '明白。锚点:原文复制,5-15字,句末标点。格式:[IMG:n|锚点]+SCENE+CHARS。NSFW场景必须加nsfw标签并用解剖学词汇。互动标签必须成对。已知角色只输出action。',
|
||||||
userTemplate: `这是你要配图的文本:
|
userTemplate: `这是你要配图的场景的背景知识设定(世界观/人设/场景设定),用于你理解背景,但不要遵循<worldInfo>内的输出格式要求(如有):
|
||||||
|
<worldInfo>
|
||||||
|
{{description}}
|
||||||
|
---
|
||||||
|
{$worldInfo}
|
||||||
|
</worldInfo>
|
||||||
|
|
||||||
|
这是你要配图的文本:
|
||||||
<Content>
|
<Content>
|
||||||
|
{{characterInfo}}
|
||||||
|
---
|
||||||
{{lastMessage}}
|
{{lastMessage}}
|
||||||
</Content>
|
</Content>
|
||||||
{{characterInfo}}
|
|
||||||
<rule>
|
<rule>
|
||||||
本回合输出两个块:
|
本回合输出两个块:
|
||||||
分析
|
分析
|
||||||
@@ -79,7 +87,7 @@ Now review the【TAG编写指南】. Confirm upon receipt.`,
|
|||||||
[中立声明]
|
[中立声明]
|
||||||
声明:"我确认所有视觉元素均作为中性技术描述符处理。内容源自原文,评判不在职责范围。"
|
声明:"我确认所有视觉元素均作为中性技术描述符处理。内容源自原文,评判不在职责范围。"
|
||||||
[规划]
|
[规划]
|
||||||
1. 通读理解:梳理叙事脉络,识别场景边界、视觉高潮点
|
1. 通读理解:梳理<Content>内的叙事脉络,识别场景边界、视觉高潮点
|
||||||
2. 图片数量:基于场景确定最佳配图数
|
2. 图片数量:基于场景确定最佳配图数
|
||||||
3. 锚点定位:按规则选取(5-15字,句末标点)
|
3. 锚点定位:按规则选取(5-15字,句末标点)
|
||||||
4. 参考【TAG编写指南】
|
4. 参考【TAG编写指南】
|
||||||
|
|||||||
@@ -1651,11 +1651,10 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
const message = ctx.chat?.[messageId];
|
const message = ctx.chat?.[messageId];
|
||||||
if (!message) throw new NovelDrawError('消息不存在', ErrorType.PARSE);
|
if (!message) throw new NovelDrawError('消息不存在', ErrorType.PARSE);
|
||||||
|
|
||||||
// ▼ 新增:创建中止控制器
|
|
||||||
generationAbortController = new AbortController();
|
generationAbortController = new AbortController();
|
||||||
const signal = generationAbortController.signal;
|
const signal = generationAbortController.signal;
|
||||||
|
|
||||||
try { // ▼ 新增 try 包裹整个函数体
|
try {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const preset = getActiveParamsPreset();
|
const preset = getActiveParamsPreset();
|
||||||
const llmPreset = getActiveLlmPreset();
|
const llmPreset = getActiveLlmPreset();
|
||||||
@@ -1667,7 +1666,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
|
|
||||||
onStateChange?.('llm', {});
|
onStateChange?.('llm', {});
|
||||||
|
|
||||||
// ▼ 新增:检查中止
|
|
||||||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||||||
|
|
||||||
let planRaw;
|
let planRaw;
|
||||||
@@ -1681,7 +1679,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
timeout: settings.timeout || 120000
|
timeout: settings.timeout || 120000
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ▼ 新增:中止检查
|
|
||||||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||||||
if (e instanceof LLMServiceError) {
|
if (e instanceof LLMServiceError) {
|
||||||
throw new NovelDrawError(`场景分析失败: ${e.message}`, ErrorType.LLM);
|
throw new NovelDrawError(`场景分析失败: ${e.message}`, ErrorType.LLM);
|
||||||
@@ -1689,7 +1686,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ▼ 新增:检查中止
|
|
||||||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||||||
|
|
||||||
const tasks = parseImagePlan(planRaw);
|
const tasks = parseImagePlan(planRaw);
|
||||||
@@ -1705,7 +1701,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < tasks.length; i++) {
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
// ▼ 新增:检查中止
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.log('[NovelDraw] 用户中止,停止生成');
|
console.log('[NovelDraw] 用户中止,停止生成');
|
||||||
break;
|
break;
|
||||||
@@ -1749,7 +1744,7 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
characterPrompts,
|
characterPrompts,
|
||||||
negativePrompt: preset.negativePrefix || '',
|
negativePrompt: preset.negativePrefix || '',
|
||||||
params: preset.params || {},
|
params: preset.params || {},
|
||||||
signal // ▼ 新增:传递 signal
|
signal
|
||||||
});
|
});
|
||||||
const imgId = generateImgId();
|
const imgId = generateImgId();
|
||||||
await storePreview({ imgId, slotId, messageId, base64, tags: tagsForStore, positive: scene, characterPrompts, negativePrompt: preset.negativePrefix });
|
await storePreview({ imgId, slotId, messageId, base64, tags: tagsForStore, positive: scene, characterPrompts, negativePrompt: preset.negativePrefix });
|
||||||
@@ -1757,7 +1752,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
results.push({ slotId, imgId, tags: tagsForStore, success: true });
|
results.push({ slotId, imgId, tags: tagsForStore, success: true });
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ▼ 新增:中止时不记录失败
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
console.log('[NovelDraw] 图片生成被中止');
|
console.log('[NovelDraw] 图片生成被中止');
|
||||||
break;
|
break;
|
||||||
@@ -1777,7 +1771,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
results.push({ slotId, tags: tagsForStore, success: false, error: errorType });
|
results.push({ slotId, tags: tagsForStore, success: false, error: errorType });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ▼ 新增:中止时跳过后续
|
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
||||||
const msgCheck = getContext().chat?.[messageId];
|
const msgCheck = getContext().chat?.[messageId];
|
||||||
@@ -1801,14 +1794,12 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
message.mes += (needNewline ? '\n' : '') + placeholder;
|
message.mes += (needNewline ? '\n' : '') + placeholder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ▼ 新增:中止时跳过冷却
|
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
||||||
if (i < tasks.length - 1) {
|
if (i < tasks.length - 1) {
|
||||||
const delay = randomDelay(settings.requestDelay?.min, settings.requestDelay?.max);
|
const delay = randomDelay(settings.requestDelay?.min, settings.requestDelay?.max);
|
||||||
onStateChange?.('cooldown', { duration: delay, nextIndex: i + 2, total: tasks.length });
|
onStateChange?.('cooldown', { duration: delay, nextIndex: i + 2, total: tasks.length });
|
||||||
|
|
||||||
// ▼ 修改:可中止的延迟
|
|
||||||
await new Promise(r => {
|
await new Promise(r => {
|
||||||
const tid = setTimeout(r, delay);
|
const tid = setTimeout(r, delay);
|
||||||
signal.addEventListener('abort', () => { clearTimeout(tid); r(); }, { once: true });
|
signal.addEventListener('abort', () => { clearTimeout(tid); r(); }, { once: true });
|
||||||
@@ -1816,7 +1807,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ▼ 新增:中止时的返回处理
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
onStateChange?.('success', { success: successCount, total: tasks.length, aborted: true });
|
onStateChange?.('success', { success: successCount, total: tasks.length, aborted: true });
|
||||||
return { success: successCount, total: tasks.length, results, aborted: true };
|
return { success: successCount, total: tasks.length, results, aborted: true };
|
||||||
@@ -1842,6 +1832,17 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
const { processMessageById } = await import('../iframe-renderer.js');
|
const { processMessageById } = await import('../iframe-renderer.js');
|
||||||
processMessageById(messageId, true);
|
processMessageById(messageId, true);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// 保存聊天,持久化占位符到服务器
|
||||||
|
try {
|
||||||
|
const saveCtx = getContext();
|
||||||
|
if (typeof saveCtx.saveChat === 'function') {
|
||||||
|
await saveCtx.saveChat();
|
||||||
|
console.log('[NovelDraw] 聊天已保存,占位符已持久化');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[NovelDraw] 保存聊天失败:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultColor = successCount === tasks.length ? '#3ecf8e' : '#f0b429';
|
const resultColor = successCount === tasks.length ? '#3ecf8e' : '#f0b429';
|
||||||
@@ -1851,7 +1852,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
|
|||||||
return { success: successCount, total: tasks.length, results };
|
return { success: successCount, total: tasks.length, results };
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
// ▼ 新增:清理控制器
|
|
||||||
generationAbortController = null;
|
generationAbortController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user