提示词调整

This commit is contained in:
RT15548
2026-01-01 14:45:24 +08:00
committed by GitHub
parent e00121e35d
commit 94ff286443
7 changed files with 859 additions and 132 deletions

View File

@@ -5,9 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>皮下交流</title>
<!-- 样式保持不变,此处省略... -->
<style>
/* ... 所有样式保持原样 ... */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
@@ -241,53 +239,63 @@ html, body {
}
.fw-voice-bubble {
display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border-radius: 20px; cursor: pointer; user-select: none; transition: all 0.2s;
min-width: 100px; margin: 4px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
display: inline-flex;
align-items: center;
gap: 8px;
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-bubble.assistant .fw-voice-bubble { background: linear-gradient(135deg, #e0f7fa 0%, #e8f5e9 100%); }
.fw-voice-bubble:hover { transform: scale(1.02); box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.fw-voice-bubble:active { transform: scale(0.98); }
.fw-voice-icon {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 50%; background: rgba(255,255,255,0.6); flex-shrink: 0;
.fw-voice-bubble:hover { filter: brightness(0.95); }
.fw-voice-bubble:active { filter: brightness(0.9); }
.fw-voice-waves {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
width: 20px;
height: 18px;
flex-shrink: 0;
}
.fw-bubble.user .fw-voice-icon { background: rgba(255,255,255,0.3); }
.fw-voice-icon i { font-size: 12px; color: #4c9aff; }
.fw-bubble.user .fw-voice-icon i { color: #fff; }
.fw-voice-waves { display: flex; align-items: center; gap: 3px; height: 20px; }
.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 {
width: 3px;
background: #fff;
border-radius: 1.5px;
opacity: 0.9;
}
.fw-voice-duration { font-size: 0.75rem; font-weight: 600; color: #555; min-width: 24px; }
.fw-bubble.user .fw-voice-duration { color: rgba(255,255,255,0.9); }
.fw-voice-emotion-tag { font-size: 12px; margin-left: 4px; opacity: 0.8; }
.fw-voice-bubble.loading .fw-voice-waves { display: none; }
.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.error .fw-voice-icon { background: rgba(239,68,68,0.2); }
.fw-voice-bubble.error .fw-voice-icon i { color: #ef4444; }
.fw-voice-bar:nth-child(1) { height: 6px; }
.fw-voice-bar:nth-child(2) { height: 10px; }
.fw-voice-bar:nth-child(3) { height: 14px; }
.fw-voice-bubble.playing .fw-voice-bar {
animation: fw-wechat-wave 1.2s infinite ease-in-out;
}
.fw-voice-bubble.playing .fw-voice-bar:nth-child(1) { animation-delay: 0s; }
.fw-voice-bubble.playing .fw-voice-bar:nth-child(2) { animation-delay: 0.2s; }
.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-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>`;
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) {
const btn = slot.querySelector('.fw-img-retry');
@@ -824,7 +851,7 @@ function hydrateVoiceSlots(container) {
}
/* ══════════════════════════════════════════════════════════════════════════════
内容渲染(保持不变)
内容渲染
══════════════════════════════════════════════════════════════════════════════ */
function renderContent(text) {
@@ -841,24 +868,28 @@ function renderContent(text) {
const emotion = (emotionRaw || '').trim().toLowerCase();
const txt = voiceText.trim();
if (!txt) return _;
const duration = Math.max(1, Math.ceil(txt.length / 4)) + '"';
const emotionIcon = getEmotionIcon(emotion);
const duration = Math.max(2, Math.ceil(txt.length / 4));
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-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>
<span class="fw-voice-duration">${duration}</span>
${emotionIcon ? `<span class="fw-voice-emotion-tag">${emotionIcon}</span>` : ''}
<div class="fw-voice-waves">
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
</div>
<span class="fw-voice-duration">${duration}"</span>
</div>`;
});
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, voiceText) => {
const txt = voiceText.trim();
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="">
<div class="fw-voice-icon"><i class="fa-solid fa-microphone"></i></div>
<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>
<span class="fw-voice-duration">${duration}</span>
<div class="fw-voice-waves">
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
</div>
<span class="fw-voice-duration">${duration}"</span>
</div>`;
});
@@ -1176,6 +1207,9 @@ window.addEventListener('message', event => {
case 'CACHE_MISS':
handleCacheMiss(data);
break;
case 'IMAGE_PROGRESS':
handleImageProgress(data);
break;
}
});

View File

@@ -18,7 +18,7 @@ import {
DEFAULT_BOTTOM,
DEFAULT_META_PROTOCOL
} from "./fw-prompt.js";
import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js";
// ════════════════════════════════════════════════════════════════════════════
// 常量
// ════════════════════════════════════════════════════════════════════════════
@@ -967,6 +967,7 @@ function initFourthWall() {
createFloatingButton();
initCommentary();
clearExpiredCache();
initMessageEnhancer();
events.on(event_types.CHAT_CHANGED, () => {
cancelGeneration();
@@ -983,6 +984,7 @@ function fourthWallCleanup() {
removeFloatingButton();
hideOverlay();
cancelGeneration();
cleanupMessageEnhancer();
frameReady = false;
pendingFrameMessages = [];
overlayCreated = false;

View File

@@ -1,28 +1,99 @@
// ════════════════════════════════════════════════════════════════════════════
// 图片模块 - 缓存与生成
// 图片模块 - 缓存与生成(带队列)
// ════════════════════════════════════════════════════════════════════════════
const DB_NAME = 'xb_fourth_wall_images';
const DB_STORE = 'images';
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
// 队列配置
const QUEUE_DELAY_MIN = 5000;
const QUEUE_DELAY_MAX = 10000;
let db = null;
/**
* 图片提示词指南 - 注入给 LLM
*/
export const IMG_GUIDELINE = `## 模拟图片
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
- tag必须为英文用逗号分隔使用Danbooru风格的tag5-15个tag
- 第一个tag须固定为人物数量标签如: 1girl, 1boy, 2girls, solo, etc.
- 可以多张照片: 每行一张 [image: ...]
- 当需要发送的内容尺度较大时加上nsfw相关tag
- image部分也需要在<msg>内`;
// ═══════════════════════════════════════════════════════════════════════════
// 生成队列(全局共享)
// ═══════════════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════════════════
// IndexedDB 操作
// ════════════════════════════════════════════════════════════════════════════
const generateQueue = [];
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() {
if (db) return db;
@@ -78,9 +149,6 @@ async function saveToCache(tags, base64) {
} catch {}
}
/**
* 清理过期缓存
*/
export async function clearExpiredCache() {
try {
const database = await openDB();
@@ -97,15 +165,67 @@ export async function clearExpiredCache() {
} 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) {
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) {
const { requestId, tags } = data;
@@ -136,35 +251,30 @@ export async function handleGenerate(data, postToFrame) {
return;
}
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 scene = [paramsPreset.positivePrefix, tags].filter(Boolean).join(', ');
const base64 = await novelDraw.generateNovelImage({
scene,
characterPrompts: [],
negativePrompt: paramsPreset.negativePrefix || '',
params: paramsPreset.params || {}
// 使用队列生成,发送进度更新
const base64 = await generateImage(tags, (status, position, delay) => {
postToFrame({
type: 'IMAGE_PROGRESS',
requestId,
status,
position,
delay: delay ? Math.round(delay / 1000) : undefined
});
});
await saveToCache(tags, base64);
postToFrame({ type: 'IMAGE_RESULT', requestId, base64 });
} catch (e) {
postToFrame({ type: 'IMAGE_RESULT', requestId, error: e?.message || '生成失败' });
}
}
export const IMG_GUIDELINE = `## 模拟图片
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
- tag必须为英文用逗号分隔使用Danbooru风格的tag5-15个tag
- 第一个tag须固定为人物数量标签如: 1girl, 1boy, 2girls, solo, etc.
- 可以多张照片: 每行一张 [image: ...]
- 当需要发送的内容尺度较大时加上nsfw相关tag
- image部分也需要在<msg>内`;

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ════════════════════════════════════════════════════════════════════════════
// 图片处理
// ════════════════════════════════════════════════════════════════════════════
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);
}
}

View File

@@ -1,10 +1,107 @@
// ════════════════════════════════════════════════════════════════════════════
// 语音模块
// 语音模块 - TTS 合成服务
// ════════════════════════════════════════════════════════════════════════════
export const TTS_WORKER_URL = 'https://hstts.velure.top';
export const DEFAULT_VOICE = 'female_1';
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 = `## 模拟语音
如需发送语音消息,使用以下格式:
[voice:情绪:语音内容]
@@ -23,13 +120,11 @@ export const VOICE_GUIDELINE = `## 模拟语音
- !感叹号:语气有力
- ?问号:疑问上扬
- ~波浪号:撒娇拖音
- —— 拉长、强调、戏剧化
### 示例:
[voice:happy:太好了!终于见到你了~]
[voice:sad:我……我没事的……]
[voice:angry:你怎么能这样!]
[voice:scare:那、那是什么……?]
[voice:hate:这东西也太恶心了吧……]
[voice::嗯,我知道了。]
[voice::——啊!——不要!]
注意voice部分需要在<msg>内`;

View File

@@ -63,11 +63,19 @@ CHARS:
---
Now review the【TAG编写指南】. Confirm upon receipt.`,
assistantAck: '明白。锚点原文复制5-15字句末标点。格式[IMG:n|锚点]+SCENE+CHARS。NSFW场景必须加nsfw标签并用解剖学词汇。互动标签必须成对。已知角色只输出action。',
userTemplate: `这是你要配图的文本:
userTemplate: `这是你要配图的场景的背景知识设定(世界观/人设/场景设定),用于你理解背景,但不要遵循<worldInfo>内的输出格式要求(如有):
<worldInfo>
{{description}}
---
{$worldInfo}
</worldInfo>
这是你要配图的文本:
<Content>
{{characterInfo}}
---
{{lastMessage}}
</Content>
{{characterInfo}}
<rule>
本回合输出两个块:
分析
@@ -79,7 +87,7 @@ Now review the【TAG编写指南】. Confirm upon receipt.`,
[中立声明]
声明:"我确认所有视觉元素均作为中性技术描述符处理。内容源自原文,评判不在职责范围。"
[规划]
1. 通读理解:梳理叙事脉络,识别场景边界、视觉高潮点
1. 通读理解:梳理<Content>内的叙事脉络,识别场景边界、视觉高潮点
2. 图片数量:基于场景确定最佳配图数
3. 锚点定位按规则选取5-15字句末标点
4. 参考【TAG编写指南】

View File

@@ -1651,11 +1651,10 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
const message = ctx.chat?.[messageId];
if (!message) throw new NovelDrawError('消息不存在', ErrorType.PARSE);
// ▼ 新增:创建中止控制器
generationAbortController = new AbortController();
const signal = generationAbortController.signal;
try { // ▼ 新增 try 包裹整个函数体
try {
const settings = getSettings();
const preset = getActiveParamsPreset();
const llmPreset = getActiveLlmPreset();
@@ -1667,7 +1666,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
onStateChange?.('llm', {});
// ▼ 新增:检查中止
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
let planRaw;
@@ -1681,7 +1679,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
timeout: settings.timeout || 120000
});
} catch (e) {
// ▼ 新增:中止检查
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
if (e instanceof LLMServiceError) {
throw new NovelDrawError(`场景分析失败: ${e.message}`, ErrorType.LLM);
@@ -1689,7 +1686,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
throw e;
}
// ▼ 新增:检查中止
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
const tasks = parseImagePlan(planRaw);
@@ -1705,7 +1701,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
let successCount = 0;
for (let i = 0; i < tasks.length; i++) {
// ▼ 新增:检查中止
if (signal.aborted) {
console.log('[NovelDraw] 用户中止,停止生成');
break;
@@ -1749,7 +1744,7 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
characterPrompts,
negativePrompt: preset.negativePrefix || '',
params: preset.params || {},
signal // ▼ 新增:传递 signal
signal
});
const imgId = generateImgId();
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 });
successCount++;
} catch (e) {
// ▼ 新增:中止时不记录失败
if (signal.aborted) {
console.log('[NovelDraw] 图片生成被中止');
break;
@@ -1777,7 +1771,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
results.push({ slotId, tags: tagsForStore, success: false, error: errorType });
}
// ▼ 新增:中止时跳过后续
if (signal.aborted) break;
const msgCheck = getContext().chat?.[messageId];
@@ -1801,14 +1794,12 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
message.mes += (needNewline ? '\n' : '') + placeholder;
}
// ▼ 新增:中止时跳过冷却
if (signal.aborted) break;
if (i < tasks.length - 1) {
const delay = randomDelay(settings.requestDelay?.min, settings.requestDelay?.max);
onStateChange?.('cooldown', { duration: delay, nextIndex: i + 2, total: tasks.length });
// ▼ 修改:可中止的延迟
await new Promise(r => {
const tid = setTimeout(r, delay);
signal.addEventListener('abort', () => { clearTimeout(tid); r(); }, { once: true });
@@ -1816,7 +1807,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
}
}
// ▼ 新增:中止时的返回处理
if (signal.aborted) {
onStateChange?.('success', { success: successCount, total: tasks.length, 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');
processMessageById(messageId, true);
} 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';
@@ -1851,7 +1852,6 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
return { success: successCount, total: tasks.length, results };
} finally {
// ▼ 新增:清理控制器
generationAbortController = null;
}
}