Add files via upload

This commit is contained in:
RT15548
2025-12-28 00:49:25 +08:00
committed by GitHub
parent 50495bfb50
commit a693c55e50
20 changed files with 12823 additions and 8731 deletions

View File

@@ -199,17 +199,93 @@ html, body {
.fw-streaming { opacity: 0.8; font-style: italic; }
.fw-empty { text-align: center; color: var(--text-muted); padding: 40px; font-size: 0.875rem; }
.fw-img-slot { margin: 8px 0; }
.fw-img-slot img { max-width: min(300px, 70vw); max-height: 50vh; border-radius: 8px; display: block; }
.fw-img-loading { font-size: 0.75rem; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.fw-img-error {
width: 200px; height: 140px; background: var(--bg-tertiary);
border: 1px dashed var(--border-color); border-radius: 8px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--text-muted); font-size: 0.75rem;
/* 图片懒加载样式 */
.fw-img-slot {
margin: 8px 0;
min-height: 80px;
position: relative;
}
.fw-img-slot img {
max-width: min(300px, 70vw);
max-height: 50vh;
border-radius: 8px;
display: block;
cursor: pointer;
transition: opacity 0.2s;
}
.fw-img-slot img:hover { opacity: 0.9; }
.fw-img-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 16px;
background: var(--bg-tertiary);
border: 1px dashed var(--border-color);
border-radius: 8px;
color: var(--text-muted);
font-size: 0.75rem;
}
.fw-img-placeholder i { font-size: 24px; opacity: 0.4; }
.fw-img-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
background: linear-gradient(135deg, rgba(76,154,255,0.08), rgba(118,75,162,0.08));
border: 1px solid rgba(76,154,255,0.15);
border-radius: 8px;
color: var(--text-secondary);
font-size: 0.8125rem;
}
.fw-img-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: rgba(248,113,113,0.08);
border: 1px dashed rgba(248,113,113,0.25);
border-radius: 8px;
color: #f87171;
font-size: 0.75rem;
text-align: center;
}
.fw-img-retry {
margin-top: 4px;
padding: 4px 12px;
background: rgba(248,113,113,0.15);
border: 1px solid rgba(248,113,113,0.25);
border-radius: 4px;
color: #f87171;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.2s;
}
.fw-img-retry:hover { background: rgba(248,113,113,0.25); }
.fw-img-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.6);
color: #fbbf24;
font-size: 10px;
padding: 3px 6px;
border-radius: 4px;
backdrop-filter: blur(4px);
}
/* 语音样式 */
.fw-voice-bubble {
display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
@@ -331,7 +407,6 @@ html, body {
.fw-prompt-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
/* 思考折叠UI - 一体化卡片设计 */
.fw-thinking-card {
margin-bottom: 6px;
background: rgba(0,0,0,0.03);
@@ -363,9 +438,7 @@ html, body {
transition: transform 0.2s;
}
.fw-thinking-header.expanded .chevron {
transform: rotate(90deg);
}
.fw-thinking-header.expanded .chevron { transform: rotate(90deg); }
.fw-thinking-body {
display: none;
@@ -391,7 +464,6 @@ html, body {
.fw-thinking-body::-webkit-scrollbar { width: 4px; }
.fw-thinking-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; }
/* 流式思考指示器 */
.fw-thinking-header.streaming span::after {
content: '';
display: inline-block;
@@ -408,18 +480,9 @@ html, body {
50% { opacity: 1; }
}
/* 编辑模式宽度最大化 */
.fw-row.editing {
max-width: 100%;
}
.fw-row.editing .fw-bubble {
width: 100%;
}
.fw-row.editing .fw-edit-area {
min-height: 80px;
}
.fw-row.editing { max-width: 100%; }
.fw-row.editing .fw-bubble { width: 100%; }
.fw-row.editing .fw-edit-area { min-height: 80px; }
@media (max-width: 600px) {
.fw-header { padding: 6px 12px; }
@@ -431,6 +494,12 @@ html, body {
.fw-bubble { padding: 8px 12px; font-size: 0.875rem; }
.fw-avatar { width: 32px; height: 32px; }
}
@media (max-width: 480px) {
.fw-container { padding: 0; }
.fw-title { font-size: 0.875rem; }
.fw-btn { padding: 4px 8px; font-size: 0.7rem; }
}
</style>
</head>
@@ -489,16 +558,9 @@ html, body {
<div class="fw-settings-group-title"><i class="fa-solid fa-photo-film"></i>媒体</div>
<div class="fw-settings-row">
<div class="fw-field">
<label>图像类型</label>
<select id="img-kind">
<option value="anime">动漫</option>
<option value="people">真人</option>
</select>
</div>
<div class="fw-field">
<input type="checkbox" id="img-prompt-enabled">
<label for="img-prompt-enabled">允许发图</label>
<label for="img-prompt-enabled">允许发图 (需开启插件NovelAI画图)</label>
</div>
</div>
<div class="fw-settings-row">
@@ -548,7 +610,7 @@ html, body {
<div class="fw-settings-group-title"><i class="fa-solid fa-comment-dots"></i>实时吐槽</div>
<div class="fw-settings-row">
<div class="fw-field">
<input type="checkbox" id="commentary-enabled" checked>
<input type="checkbox" id="commentary-enabled">
<label for="commentary-enabled">实时吐槽</label>
</div>
<div class="fw-field fw-commentary-prob-wrap">
@@ -609,22 +671,17 @@ html, body {
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
}
function renderThinking(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/^[\\*\\-]\\s+/gm, '• ')
.replace(/\n/g, '<br>');
return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/^[\\*\\-]\\s+/gm, '• ').replace(/\n/g, '<br>');
}
function updateFullscreenButton(isFullscreen) {
@@ -632,34 +689,10 @@ function updateFullscreenButton(isFullscreen) {
if (!btn) return;
const icon = btn.querySelector('i');
if (!icon) return;
if (isFullscreen) {
icon.className = 'fa-solid fa-compress';
btn.title = '退出全屏';
} else {
icon.className = 'fa-solid fa-expand';
btn.title = '全屏';
}
icon.className = isFullscreen ? 'fa-solid fa-compress' : 'fa-solid fa-expand';
btn.title = isFullscreen ? '退出全屏' : '全屏';
}
const TTS_WORKER = 'https://tts.velure.top';
let state = {
history: [],
isStreaming: false,
editingIndex: null,
menuExpanded: false,
settingsOpen: false,
settings: { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true },
sessions: [],
activeSessionId: null,
imgSettings: { categoryPreference: 'anime', enablePrompt: false },
voiceSettings: { enabled: false, voice: '桃夭', speed: 0.8 },
commentarySettings: { enabled: true, probability: 30 },
promptTemplates: {}
};
let currentAudio = null;
function formatTimeDisplay(ts) {
if (!ts) return '';
const date = new Date(ts);
@@ -678,60 +711,169 @@ function postToParent(payload) {
window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, '*');
}
const FW_IMG = { proxy: 'https://wallhaven.velure.top/?url=', maxPickSpan: 24, cacheTTLms: 600000 };
const imageCache = new Map();
// ═══════════════════════════════════════════════════════════════════════════
// 状态管理
// ═══════════════════════════════════════════════════════════════════════════
const TTS_WORKER = 'https://tts.velure.top';
let state = {
history: [],
isStreaming: false,
editingIndex: null,
menuExpanded: false,
settingsOpen: false,
settings: { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true },
sessions: [],
activeSessionId: null,
imgSettings: { enablePrompt: false },
voiceSettings: { enabled: false, voice: '桃夭', speed: 0.8 },
commentarySettings: { enabled: false, probability: 30 },
promptTemplates: {}
};
let currentAudio = null;
// ═══════════════════════════════════════════════════════════════════════════
// 图片懒加载系统
// ═══════════════════════════════════════════════════════════════════════════
const pendingImages = new Map();
const generatingQueue = new Set();
let imageObserver = null;
function parseImageToken(rawCSV) {
let txt = String(rawCSV || '').trim();
let isNSFW = false;
while (true) {
const m = txt.match(/^(nsfw|sketchy)\s*:\s*/i);
if (!m) break;
isNSFW = true;
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, '');
}
return { tagCSV: txt.split(',').map(s => s.trim().toLowerCase()).filter(Boolean).join(','), isNSFW };
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, 'nsfw, ');
return txt.split(',').map(s => s.trim()).filter(Boolean).join(', ');
}
async function searchWallhaven(tagCSV, { category, purity }) {
const q = tagCSV.split(',').filter(Boolean).join(' ');
const api = `https://wallhaven.cc/api/v1/search?q=${encodeURIComponent(q)}&categories=${category}&purity=${purity}&ratios=${encodeURIComponent('9x16,10x16,1x1,16x9,16x10,21x9')}&sorting=favorites&page=1`;
const res = await fetch(FW_IMG.proxy + encodeURIComponent(api));
if (!res.ok) throw new Error('Search failed');
const data = await res.json();
const list = data?.data || [];
if (list.length) {
const pick = list[Math.floor(Math.random() * Math.min(FW_IMG.maxPickSpan, list.length))];
return { ok: true, url: FW_IMG.proxy + encodeURIComponent(pick.path) };
}
return { ok: false };
}
async function hydrateImageSlots(container) {
for (const slot of container.querySelectorAll('.fw-img-slot:not([data-loaded])')) {
slot.setAttribute('data-loaded', '1');
const raw = decodeURIComponent(slot.dataset.raw || '');
const { tagCSV, isNSFW } = parseImageToken(raw);
if (!tagCSV) continue;
const catMap = { anime: '010', people: '001' };
const category = catMap[state.imgSettings.categoryPreference] || '010';
const purity = isNSFW ? '001' : '111';
const cacheKey = [tagCSV, purity, category].join('|');
try {
let rec = imageCache.get(cacheKey);
if (!rec || Date.now() - rec.at > FW_IMG.cacheTTLms) {
const result = await searchWallhaven(tagCSV, { category, purity });
if (!result.ok) throw new Error('No results');
rec = { url: result.url, at: Date.now() };
imageCache.set(cacheKey, rec);
function initImageObserver() {
if (imageObserver) return;
imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const slot = entry.target;
if (slot.dataset.loaded === '1' || slot.dataset.loading === '1') return;
slot.dataset.loading = '1';
const tags = parseImageToken(decodeURIComponent(slot.dataset.raw || ''));
if (!tags) return;
const requestId = 'fwimg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
pendingImages.set(requestId, { slot, tags });
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-search"></i> 查询缓存...</div>`;
postToParent({ type: 'CHECK_IMAGE_CACHE', requestId, tags });
}
slot.innerHTML = `<a href="${rec.url}" target="_blank"><img src="${rec.url}" alt="${tagCSV}"></a>`;
} catch {
slot.innerHTML = `<div class="fw-img-error"><i class="fa-solid fa-image"></i><div>无法加载图片</div><div style="font-size:10px;">${tagCSV}</div></div>`;
});
}, {
root: document.getElementById('messages'),
rootMargin: '150px 0px',
threshold: 0.01
});
}
function hydrateImageSlots(container) {
initImageObserver();
container.querySelectorAll('.fw-img-slot:not([data-observed])').forEach(slot => {
slot.setAttribute('data-observed', '1');
if (!slot.dataset.loaded && !slot.dataset.loading) {
slot.innerHTML = `
<div class="fw-img-placeholder">
<i class="fa-regular fa-image"></i>
<span>滚动加载</span>
</div>
`;
}
imageObserver.observe(slot);
});
}
function handleImageResult(data) {
const pending = pendingImages.get(data.requestId);
if (!pending) return;
const { slot, tags } = pending;
pendingImages.delete(data.requestId);
generatingQueue.delete(tags);
slot.dataset.loaded = '1';
slot.dataset.loading = '';
if (data.error) {
slot.innerHTML = `
<div class="fw-img-error">
<i class="fa-solid fa-exclamation-triangle"></i>
<div>${escapeHtml(data.error)}</div>
<button class="fw-img-retry" data-tags="${encodeURIComponent(tags)}">重试</button>
</div>
`;
bindRetryButton(slot);
} else if (data.base64) {
const img = document.createElement('img');
img.src = `data:image/png;base64,${data.base64}`;
img.alt = 'Generated';
img.onclick = () => window.open(img.src, '_blank');
slot.innerHTML = '';
slot.appendChild(img);
if (data.fromCache) {
const badge = document.createElement('span');
badge.className = 'fw-img-badge';
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
badge.title = '来自缓存';
slot.appendChild(badge);
}
}
}
function handleCacheMiss(data) {
const pending = pendingImages.get(data.requestId);
if (!pending) return;
const { slot, tags } = pending;
if (generatingQueue.has(tags)) {
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中...</div>`;
return;
}
generatingQueue.add(tags);
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中...</div>`;
postToParent({ type: 'GENERATE_IMAGE', requestId: data.requestId, tags });
}
function bindRetryButton(slot) {
const btn = slot.querySelector('.fw-img-retry');
if (!btn) return;
btn.onclick = (e) => {
e.stopPropagation();
const tags = decodeURIComponent(btn.dataset.tags || '');
if (!tags) return;
slot.dataset.loaded = '';
slot.dataset.loading = '1';
const requestId = 'fwimg_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
pendingImages.set(requestId, { slot, tags });
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 重新生成...</div>`;
postToParent({ type: 'GENERATE_IMAGE', requestId, tags });
};
}
// ═══════════════════════════════════════════════════════════════════════════
// 语音处理
// ═══════════════════════════════════════════════════════════════════════════
async function playVoice(text, bubbleEl) {
if (currentAudio) {
currentAudio.pause();
@@ -781,15 +923,18 @@ function hydrateVoiceSlots(container) {
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 内容渲染
// ═══════════════════════════════════════════════════════════════════════════
function renderContent(text) {
if (!text) return '';
let html = String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/\[(?:image|图片)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
const { tagCSV } = parseImageToken(inner);
if (!tagCSV) return _;
const key = btoa(unescape(encodeURIComponent(tagCSV))).replace(/=+$/, '');
return `<div class="fw-img-slot" data-raw="${encodeURIComponent(inner)}" id="fwimg_${key}"><div class="fw-img-loading"><i class="fa-solid fa-spinner fa-spin"></i> 加载中...</div></div>`;
const tags = parseImageToken(inner);
if (!tags) return _;
return `<div class="fw-img-slot" data-raw="${encodeURIComponent(inner)}"></div>`;
});
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
@@ -805,7 +950,7 @@ function renderContent(text) {
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(255,255,255,0.1);padding:2px 4px;border-radius:3px;">$1</code>');
html = html.replace(/`([^`]+)`/g, '<code style="background:rgba(0,0,0,0.06);padding:2px 4px;border-radius:3px;">$1</code>');
html = html.replace(/\n/g, '<br>');
return html;
}
@@ -875,7 +1020,10 @@ function renderMessages() {
hydrateImageSlots(container);
hydrateVoiceSlots(container);
container.scrollTop = container.scrollHeight;
bindMessageEvents(container);
}
function bindMessageEvents(container) {
container.querySelectorAll('.fw-thinking-header:not(.streaming)').forEach(header => {
header.onclick = () => {
const idx = header.dataset.index;
@@ -885,22 +1033,44 @@ function renderMessages() {
});
container.querySelectorAll('.fw-edit-btn').forEach(btn => {
btn.onclick = () => { state.editingIndex = parseInt(btn.dataset.index); renderMessages(); const ta = container.querySelector('.fw-edit-area'); if (ta) { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; ta.focus(); } };
btn.onclick = () => {
state.editingIndex = parseInt(btn.dataset.index);
renderMessages();
const ta = container.querySelector('.fw-edit-area');
if (ta) { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; ta.focus(); }
};
});
container.querySelectorAll('.fw-save-btn').forEach(btn => {
btn.onclick = () => { const idx = parseInt(btn.dataset.index); const ta = container.querySelector(`.fw-edit-area[data-index="${idx}"]`); if (ta) state.history[idx].content = ta.value; state.editingIndex = null; renderMessages(); postToParent({ type: 'SAVE_HISTORY', history: state.history }); };
btn.onclick = () => {
const idx = parseInt(btn.dataset.index);
const ta = container.querySelector(`.fw-edit-area[data-index="${idx}"]`);
if (ta) state.history[idx].content = ta.value;
state.editingIndex = null;
renderMessages();
postToParent({ type: 'SAVE_HISTORY', history: state.history });
};
});
container.querySelectorAll('.fw-cancel-btn').forEach(btn => {
btn.onclick = () => { state.editingIndex = null; renderMessages(); };
});
container.querySelectorAll('.fw-delete-btn').forEach(btn => {
btn.onclick = () => { if (confirm('确定要删除这条消息吗?')) { state.history.splice(parseInt(btn.dataset.index), 1); renderMessages(); postToParent({ type: 'SAVE_HISTORY', history: state.history }); } };
btn.onclick = () => {
if (confirm('确定要删除这条消息吗?')) {
state.history.splice(parseInt(btn.dataset.index), 1);
renderMessages();
postToParent({ type: 'SAVE_HISTORY', history: state.history });
}
};
});
container.querySelectorAll('.fw-edit-area').forEach(ta => {
ta.oninput = function() { this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px'; };
});
}
// ═══════════════════════════════════════════════════════════════════════════
// UI 更新函数
// ═══════════════════════════════════════════════════════════════════════════
function renderSessionSelect() {
document.getElementById('session-select').innerHTML = state.sessions.map(s =>
`<option value="${s.id}" ${s.id === state.activeSessionId ? 'selected' : ''}>${s.name || s.id}</option>`
@@ -946,6 +1116,10 @@ function loadPromptFields() {
document.getElementById('prompt-bottom').value = t.bottom || '';
}
// ═══════════════════════════════════════════════════════════════════════════
// 消息发送
// ═══════════════════════════════════════════════════════════════════════════
function sendMessage() {
const textarea = document.getElementById('input-textarea');
const text = textarea.value.trim();
@@ -973,6 +1147,10 @@ function regenerate() {
postToParent({ type: 'REGENERATE', userInput: lastUserText, history: state.history, settings: state.settings, imgSettings: state.imgSettings, voiceSettings: state.voiceSettings });
}
// ═══════════════════════════════════════════════════════════════════════════
// 消息处理
// ═══════════════════════════════════════════════════════════════════════════
window.addEventListener('message', event => {
const data = event.data;
if (!data || data.source !== 'LittleWhiteBox') return;
@@ -996,7 +1174,6 @@ window.addEventListener('message', event => {
document.getElementById('layers-input').value = state.settings.maxChatLayers;
document.getElementById('turns-input').value = state.settings.maxMetaTurns;
document.getElementById('stream-enabled').checked = state.settings.stream;
document.getElementById('img-kind').value = state.imgSettings.categoryPreference;
document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt;
document.getElementById('voice-enabled').checked = state.voiceSettings.enabled;
document.getElementById('voice-select').value = state.voiceSettings.voice || '桃夭';
@@ -1044,15 +1221,27 @@ window.addEventListener('message', event => {
case 'FULLSCREEN_STATE':
updateFullscreenButton(data.isFullscreen);
break;
case 'IMAGE_RESULT':
handleImageResult(data);
break;
case 'CACHE_MISS':
handleCacheMiss(data);
break;
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); };
document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); };
document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); };
document.getElementById('btn-close').onclick = () => postToParent({ type: 'CLOSE_OVERLAY' });
document.getElementById('btn-fullscreen').onclick = () => { postToParent({ type: 'TOGGLE_FULLSCREEN' }); };
document.getElementById('btn-fullscreen').onclick = () => postToParent({ type: 'TOGGLE_FULLSCREEN' });
document.getElementById('btn-reset').onclick = () => {
if (confirm('确定要清空当前对话吗?')) { state.history = []; renderMessages(); postToParent({ type: 'RESET_HISTORY' }); }
@@ -1067,8 +1256,10 @@ document.addEventListener('DOMContentLoaded', () => {
};
});
document.getElementById('img-kind').onchange = () => { state.imgSettings.categoryPreference = document.getElementById('img-kind').value; postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings }); };
document.getElementById('img-prompt-enabled').onchange = () => { state.imgSettings.enablePrompt = document.getElementById('img-prompt-enabled').checked; postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings }); };
document.getElementById('img-prompt-enabled').onchange = () => {
state.imgSettings.enablePrompt = document.getElementById('img-prompt-enabled').checked;
postToParent({ type: 'SAVE_IMG_SETTINGS', imgSettings: state.imgSettings });
};
document.getElementById('voice-enabled').onchange = function() { state.voiceSettings.enabled = this.checked; updateVoiceUI(this.checked); postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
document.getElementById('voice-select').onchange = function() { state.voiceSettings.voice = this.value; postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); };
@@ -1112,4 +1303,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
</script>
</body>
</html>
</html>

View File

@@ -5,7 +5,9 @@ import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents, event_types } from "../../core/event-manager.js";
import { xbLog } from "../../core/debug-core.js";
// ================== 常量定义 ==================
// ════════════════════════════════════════════════════════════════════════════
// 常量定义
// ════════════════════════════════════════════════════════════════════════════
const events = createModuleEvents('fourthWall');
const iframePath = `${extensionFolderPath}/modules/fourth-wall/fourth-wall.html`;
@@ -14,11 +16,11 @@ const COMMENTARY_COOLDOWN = 180000;
const IMG_GUIDELINE = `## 模拟图片
如果需要发图、照片给对方时,可以在聊天文本中穿插以下格式行,进行图片模拟:
[image: Person/Subject, Appearance/Clothing, Background/Environment, Atmosphere/Lighting, Extra descriptors]
- tag必须为英文用逗号分隔使用Wallhaven常见、可用的tag组合5-8个tag
- 第一个tag须固定为这四个人物标签之一:[boy, girl, man, woman]
[image: Subject, Appearance, Background, Atmosphere, Extra descriptors]
- tag必须为英文用逗号分隔使用Danbooru风格的tag5-15个tag
- 第一个tag须固定为人物数量标签,如: 1girl, 1boy, 2girls, solo, etc.
- 可以多张照片: 每行一张 [image: ...]
- 模拟社交软件发图的真实感,当需要发送的内容尺度较大时必须加上nsfw:前缀,即[image: nsfw: ...]
- 当需要发送的内容尺度较大时加上nsfw相关tag
- image部分也需要在<msg>内`;
const VOICE_GUIDELINE = `## 模拟语音
@@ -29,11 +31,6 @@ const VOICE_GUIDELINE = `## 模拟语音
- ……省略号:拖长音、犹豫、伤感
- !感叹号:语气有力、激动
- ?问号:疑问语调、尾音上扬
### 示例:
[voice: 你好,今天天气真好。] 普通
[voice: 我……不太确定……] 犹豫/拖长
[voice: 太好了!我成功了!] 激动
[voice: 你确定吗?] 疑问
- voice部分也需要在<msg>内`;
const DEFAULT_META_PROTOCOL = `
@@ -120,7 +117,9 @@ const COMMENTARY_PROTOCOL = `
只输出一个<msg>...</msg>块。不要添加任何其他格式
</meta_protocol>`;
// ================== 状态变量 ==================
// ════════════════════════════════════════════════════════════════════════════
// 状态变量
// ════════════════════════════════════════════════════════════════════════════
let overlayCreated = false;
let frameReady = false;
@@ -135,28 +134,98 @@ let lastCommentaryTime = 0;
let commentaryBubbleEl = null;
let commentaryBubbleTimer = null;
// ================== 设置管理 ==================
// ════════════════════════════════════════════════════════════════════════════
// 图片缓存 (IndexedDB)
// ════════════════════════════════════════════════════════════════════════════
const FW_IMG_DB_NAME = 'xb_fourth_wall_images';
const FW_IMG_DB_STORE = 'images';
const FW_IMG_CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
let fwImgDb = null;
async function openFWImgDB() {
if (fwImgDb) return fwImgDb;
return new Promise((resolve, reject) => {
const request = indexedDB.open(FW_IMG_DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => { fwImgDb = request.result; resolve(fwImgDb); };
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(FW_IMG_DB_STORE)) {
db.createObjectStore(FW_IMG_DB_STORE, { keyPath: 'hash' });
}
};
});
}
function hashTags(tags) {
let hash = 0;
const str = String(tags || '').toLowerCase().replace(/\s+/g, ' ').trim();
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return 'fw_' + Math.abs(hash).toString(36);
}
async function getCachedImage(tags) {
try {
const db = await openFWImgDB();
const hash = hashTags(tags);
return new Promise((resolve) => {
const tx = db.transaction(FW_IMG_DB_STORE, 'readonly');
const req = tx.objectStore(FW_IMG_DB_STORE).get(hash);
req.onsuccess = () => {
const result = req.result;
if (result && Date.now() - result.timestamp < FW_IMG_CACHE_TTL) {
resolve(result.base64);
} else {
resolve(null);
}
};
req.onerror = () => resolve(null);
});
} catch { return null; }
}
async function cacheImage(tags, base64) {
try {
const db = await openFWImgDB();
const hash = hashTags(tags);
const tx = db.transaction(FW_IMG_DB_STORE, 'readwrite');
tx.objectStore(FW_IMG_DB_STORE).put({ hash, tags, base64, timestamp: Date.now() });
} catch {}
}
async function clearExpiredFWImageCache() {
try {
const db = await openFWImgDB();
const cutoff = Date.now() - FW_IMG_CACHE_TTL;
const tx = db.transaction(FW_IMG_DB_STORE, 'readwrite');
const store = tx.objectStore(FW_IMG_DB_STORE);
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
if (cursor.value.timestamp < cutoff) cursor.delete();
cursor.continue();
}
};
} catch {}
}
// ════════════════════════════════════════════════════════════════════════════
// 设置管理
// ════════════════════════════════════════════════════════════════════════════
function getSettings() {
extension_settings[EXT_ID] ||= {};
const s = extension_settings[EXT_ID];
s.fourthWall ||= { enabled: true };
s.fourthWallImage ||= {
categoryPreference: 'anime',
purityDefault: '111',
purityWhenNSFW: '001',
enablePrompt: false,
};
s.fourthWallVoice ||= {
enabled: false,
voice: '桃夭',
speed: 0.5,
};
s.fourthWallCommentary ||= {
enabled: false,
probability: 30
};
s.fourthWallImage ||= { enablePrompt: false };
s.fourthWallVoice ||= { enabled: false, voice: '桃夭', speed: 0.5 };
s.fourthWallCommentary ||= { enabled: false, probability: 30 };
s.fourthWallPromptTemplates ||= {};
const t = s.fourthWallPromptTemplates;
@@ -173,23 +242,17 @@ Scene_Description_Requirements:
- Symbolism_and_Implication: Use personification and symbolism to add depth and subtlety to scenes.
</task_settings>`;
}
if (t.confirm === undefined) {
t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。';
}
if (t.bottom === undefined) {
t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照<meta_protocol>内要求,进行<thinking>和<msg>互动,开始内省:`;
}
if (t.metaProtocol === undefined) {
t.metaProtocol = DEFAULT_META_PROTOCOL;
}
if (t.imgGuideline === undefined) {
t.imgGuideline = IMG_GUIDELINE;
}
if (t.confirm === undefined) t.confirm = '好的,我已阅读设置要求,准备查看历史并进入角色。';
if (t.bottom === undefined) t.bottom = `我将根据你的回应: {{USER_INPUT}}|按照<meta_protocol>内要求,进行<thinking>和<msg>互动,开始内省:`;
if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL;
if (t.imgGuideline === undefined) t.imgGuideline = IMG_GUIDELINE;
return s;
}
// ================== 工具函数 ==================
// ════════════════════════════════════════════════════════════════════════════
// 工具函数
// ════════════════════════════════════════════════════════════════════════════
function b64UrlEncode(str) {
const utf8 = new TextEncoder().encode(String(str));
@@ -301,10 +364,7 @@ function getAvatarUrls() {
}
return '';
};
let user = pickSrc([
'#user_avatar_block img', '#avatar_user img', '.user_avatar img',
'img#avatar_user', '.st-user-avatar img'
]) || (typeof default_user_avatar !== 'undefined' ? default_user_avatar : '');
let user = pickSrc(['#user_avatar_block img', '#avatar_user img', '.user_avatar img', 'img#avatar_user', '.st-user-avatar img']) || (typeof default_user_avatar !== 'undefined' ? default_user_avatar : '');
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
if (m) user = `User Avatars/${decodeURIComponent(m[1])}`;
const ctx = getContext?.() || {};
@@ -336,7 +396,9 @@ async function getUserAndCharNames() {
return { userName, charName };
}
// ================== 存储管理 ==================
// ════════════════════════════════════════════════════════════════════════════
// 存储管理
// ════════════════════════════════════════════════════════════════════════════
function getFWStore(chatId = getCurrentChatIdSafe()) {
if (!chatId) return null;
@@ -371,7 +433,9 @@ function saveFWStore() {
saveMetadataDebounced?.();
}
// ================== iframe 通讯 ==================
// ════════════════════════════════════════════════════════════════════════════
// iframe 通讯
// ════════════════════════════════════════════════════════════════════════════
function postToFrame(payload) {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
@@ -410,6 +474,59 @@ function sendInitData() {
});
}
// ════════════════════════════════════════════════════════════════════════════
// NovelDraw 图片生成 (带缓存)
// ════════════════════════════════════════════════════════════════════════════
async function handleCheckImageCache(data) {
const { requestId, tags } = data;
const cached = await getCachedImage(tags);
if (cached) {
postToFrame({ type: 'IMAGE_RESULT', requestId, base64: cached, fromCache: true });
} else {
postToFrame({ type: 'CACHE_MISS', requestId, tags });
}
}
async function handleGenerateImage(data) {
const { requestId, tags } = data;
const novelDraw = window.xiaobaixNovelDraw;
if (!novelDraw) {
postToFrame({ type: 'IMAGE_RESULT', requestId, error: 'NovelDraw 模块未启用' });
return;
}
try {
const settings = novelDraw.getSettings();
const paramsPreset = settings.paramsPresets?.find(p => p.id === settings.selectedParamsPresetId) || settings.paramsPresets?.[0];
if (!paramsPreset) {
postToFrame({ type: 'IMAGE_RESULT', requestId, error: '无可用的参数预设' });
return;
}
const positive = [paramsPreset.positivePrefix, tags].filter(Boolean).join(', ');
const base64 = await novelDraw.generateNovelImage({
prompt: positive,
negativePrompt: paramsPreset.negativePrefix || '',
params: paramsPreset.params || {},
characters: []
});
await cacheImage(tags, base64);
postToFrame({ type: 'IMAGE_RESULT', requestId, base64 });
} catch (e) {
console.error('[FourthWall] 图片生成失败:', e);
postToFrame({ type: 'IMAGE_RESULT', requestId, error: e.message || '生成失败' });
}
}
// ════════════════════════════════════════════════════════════════════════════
// 消息处理
// ════════════════════════════════════════════════════════════════════════════
function handleFrameMessage(event) {
const data = event.data;
if (!data || data.source !== 'LittleWhiteBox-FourthWall') return;
@@ -529,10 +646,20 @@ function handleFrameMessage(event) {
case 'CLOSE_OVERLAY':
hideOverlay();
break;
case 'CHECK_IMAGE_CACHE':
handleCheckImageCache(data);
break;
case 'GENERATE_IMAGE':
handleGenerateImage(data);
break;
}
}
// ================== Prompt 构建 ==================
// ════════════════════════════════════════════════════════════════════════════
// Prompt 构建
// ════════════════════════════════════════════════════════════════════════════
async function buildPrompt(userInput, history, settings, imgSettings, voiceSettings, isCommentary = false) {
const { userName, charName } = await getUserAndCharNames();
@@ -579,10 +706,7 @@ async function buildPrompt(userInput, history, settings, imgSettings, voiceSetti
})
.join('\n');
const msg1 = String(T.topuser || '')
.replace(/{{USER_NAME}}/g, userName)
.replace(/{{CHAR_NAME}}/g, charName);
const msg1 = String(T.topuser || '').replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName);
const msg2 = String(T.confirm || '好的,我已阅读设置要求,准备查看历史并进入角色。');
let metaProtocol = (isCommentary ? COMMENTARY_PROTOCOL : String(T.metaProtocol || '')).replace(/{{USER_NAME}}/g, userName).replace(/{{CHAR_NAME}}/g, charName);
@@ -604,7 +728,9 @@ ${metaProtocol}`.replace(/\|/g, '').trim();
return { msg1, msg2, msg3, msg4 };
}
// ================== 生成处理 ==================
// ════════════════════════════════════════════════════════════════════════════
// 生成处理
// ════════════════════════════════════════════════════════════════════════════
async function handleSendMessage(data) {
if (isStreaming) return;
@@ -616,25 +742,15 @@ async function handleSendMessage(data) {
saveFWStore();
}
const { msg1, msg2, msg3, msg4 } = await buildPrompt(
data.userInput,
data.history,
data.settings,
data.imgSettings,
data.voiceSettings
);
const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings);
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
try {
await executeSlashCommand(cmd);
if (data.settings.stream) {
startStreamingPoll();
} else {
startNonstreamAwait();
}
if (data.settings.stream) startStreamingPoll();
else startNonstreamAwait();
} catch {
stopStreamingPoll();
isStreaming = false;
@@ -652,25 +768,15 @@ async function handleRegenerate(data) {
saveFWStore();
}
const { msg1, msg2, msg3, msg4 } = await buildPrompt(
data.userInput,
data.history,
data.settings,
data.imgSettings,
data.voiceSettings
);
const { msg1, msg2, msg3, msg4 } = await buildPrompt(data.userInput, data.history, data.settings, data.imgSettings, data.voiceSettings);
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
const nonstreamArg = data.settings.stream ? '' : ' nonstream=true';
const cmd = `/xbgenraw id=${STREAM_SESSION_ID} top64="${top64}"${nonstreamArg} ""`;
try {
await executeSlashCommand(cmd);
if (data.settings.stream) {
startStreamingPoll();
} else {
startNonstreamAwait();
}
if (data.settings.stream) startStreamingPoll();
else startNonstreamAwait();
} catch {
stopStreamingPoll();
isStreaming = false;
@@ -687,16 +793,10 @@ function startStreamingPoll() {
const raw = gen.getLastGeneration(STREAM_SESSION_ID) || '...';
const thinking = extractThinkingPartial(raw);
const msg = extractMsg(raw) || extractMsgPartial(raw);
postToFrame({
type: 'STREAM_UPDATE',
text: msg || '...',
thinking: thinking || undefined
});
postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined });
const st = gen.getStatus?.(STREAM_SESSION_ID);
if (st && st.isStreaming === false) {
finalizeGeneration();
}
if (st && st.isStreaming === false) finalizeGeneration();
}, 80);
}
@@ -705,9 +805,7 @@ function startNonstreamAwait() {
streamTimerId = setInterval(() => {
const gen = window.xiaobaixStreamingGeneration;
const st = gen?.getStatus?.(STREAM_SESSION_ID);
if (st && st.isStreaming === false) {
finalizeGeneration();
}
if (st && st.isStreaming === false) finalizeGeneration();
}, 120);
}
@@ -729,12 +827,7 @@ function finalizeGeneration() {
const session = getActiveSession();
if (session) {
session.history.push({
role: 'ai',
content: finalText,
thinking: thinkingText || undefined,
ts: Date.now()
});
session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() });
saveFWStore();
}
@@ -749,7 +842,9 @@ function cancelGeneration() {
postToFrame({ type: 'GENERATION_CANCELLED' });
}
// ================== 实时吐槽 ==================
// ════════════════════════════════════════════════════════════════════════════
// 实时吐槽
// ════════════════════════════════════════════════════════════════════════════
function shouldTriggerCommentary() {
const settings = getSettings();
@@ -766,25 +861,15 @@ async function buildCommentaryPrompt(targetText, type) {
const session = getActiveSession();
if (!store || !session) return null;
const { msg1, msg2, msg3 } = await buildPrompt(
'',
session.history || [],
store.settings || {},
settings.fourthWallImage || {},
settings.fourthWallVoice || {},
true
);
const { msg1, msg2, msg3 } = await buildPrompt('', session.history || [], store.settings || {}, settings.fourthWallImage || {}, settings.fourthWallVoice || {}, true);
let msg4;
if (type === 'ai_message') {
msg4 = `现在<chat_history>剧本还在继续中我刚才说完最后一轮rp忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。
我将直接输出<msg>内容</msg>:`;
msg4 = `现在<chat_history>剧本还在继续中我刚才说完最后一轮rp忍不住想皮下吐槽一句自己的rp(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
} else if (type === 'edit_own') {
msg4 = `现在<chat_history>剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}
必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
msg4 = `现在<chat_history>剧本还在继续中,我发现你刚才悄悄编辑了自己的台词!是:「${String(targetText || '')}必须皮下吐槽一句(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:`;
} else if (type === 'edit_ai') {
msg4 = `现在<chat_history>剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}
必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:。`;
msg4 = `现在<chat_history>剧本还在继续中,我发现你居然偷偷改了我的台词!是:「${String(targetText || '')}必须皮下吐槽一下(也可以稍微衔接之前的meta_history)。我将直接输出<msg>内容</msg>:。`;
}
return { msg1, msg2, msg3, msg4 };
@@ -794,7 +879,6 @@ async function generateCommentary(targetText, type) {
const built = await buildCommentaryPrompt(targetText, type);
if (!built) return null;
const { msg1, msg2, msg3, msg4 } = built;
const top64 = b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
try {
@@ -874,16 +958,8 @@ function getFloatBtnPosition() {
if (!btn) return null;
const rect = btn.getBoundingClientRect();
let stored = {};
try {
stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {};
} catch {}
return {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
side: stored.side || 'right'
};
try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch {}
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' };
}
function showCommentaryBubble(text) {
@@ -895,19 +971,9 @@ function showCommentaryBubble(text) {
bubble.textContent = text;
bubble.onclick = hideCommentaryBubble;
Object.assign(bubble.style, {
position: 'fixed',
zIndex: '10000',
maxWidth: '200px',
padding: '8px 12px',
background: 'rgba(255,255,255,0.95)',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
fontSize: '13px',
color: '#333',
cursor: 'pointer',
opacity: '0',
transform: 'scale(0.8)',
transition: 'opacity 0.3s, transform 0.3s'
position: 'fixed', zIndex: '10000', maxWidth: '200px', padding: '8px 12px',
background: 'rgba(255,255,255,0.95)', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
fontSize: '13px', color: '#333', cursor: 'pointer', opacity: '0', transform: 'scale(0.8)', transition: 'opacity 0.3s, transform 0.3s'
});
document.body.appendChild(bubble);
commentaryBubbleEl = bubble;
@@ -930,10 +996,7 @@ function showCommentaryBubble(text) {
bubble.style.right = '';
bubble.style.borderBottomLeftRadius = '4px';
}
requestAnimationFrame(() => {
bubble.style.opacity = '1';
bubble.style.transform = 'scale(1)';
});
requestAnimationFrame(() => { bubble.style.opacity = '1'; bubble.style.transform = 'scale(1)'; });
const len = (text || '').length;
const duration = Math.min(2000 + Math.ceil(len / 5) * 1000, 8000);
commentaryBubbleTimer = setTimeout(hideCommentaryBubble, duration);
@@ -941,17 +1004,11 @@ function showCommentaryBubble(text) {
}
function hideCommentaryBubble() {
if (commentaryBubbleTimer) {
clearTimeout(commentaryBubbleTimer);
commentaryBubbleTimer = null;
}
if (commentaryBubbleTimer) { clearTimeout(commentaryBubbleTimer); commentaryBubbleTimer = null; }
if (commentaryBubbleEl) {
commentaryBubbleEl.style.opacity = '0';
commentaryBubbleEl.style.transform = 'scale(0.8)';
setTimeout(() => {
commentaryBubbleEl?.remove();
commentaryBubbleEl = null;
}, 300);
setTimeout(() => { commentaryBubbleEl?.remove(); commentaryBubbleEl = null; }, 300);
}
}
@@ -967,7 +1024,9 @@ function cleanupCommentary() {
lastCommentaryTime = 0;
}
// ================== Overlay 管理 ==================
// ════════════════════════════════════════════════════════════════════════════
// Overlay 管理
// ════════════════════════════════════════════════════════════════════════════
function createOverlay() {
if (overlayCreated) return;
@@ -979,26 +1038,10 @@ function createOverlay() {
const framePadding = isMobile ? 'padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important;' : '';
const $overlay = $(`
<div id="xiaobaix-fourth-wall-overlay" style="
position: fixed !important; inset: 0 !important;
width: 100vw !important; height: 100vh !important; height: 100dvh !important;
z-index: 99999 !important; display: none; overflow: hidden !important;
background: #000 !important;
">
<div class="fw-backdrop" style="
position: absolute !important; inset: 0 !important;
background: rgba(0,0,0,.55) !important;
backdrop-filter: blur(4px) !important;
"></div>
<div class="fw-frame-wrap" style="
position: absolute !important; inset: ${frameInset} !important; z-index: 1 !important; ${framePadding}
">
<iframe id="xiaobaix-fourth-wall-iframe" class="xiaobaix-iframe"
src="${iframePath}"
style="width:100% !important; height:100% !important; border:none !important;
border-radius:${iframeRadius} !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
background:#1a1a2e !important;">
</iframe>
<div id="xiaobaix-fourth-wall-overlay" style="position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;height:100dvh!important;z-index:99999!important;display:none;overflow:hidden!important;background:#000!important;">
<div class="fw-backdrop" style="position:absolute!important;inset:0!important;background:rgba(0,0,0,.55)!important;backdrop-filter:blur(4px)!important;"></div>
<div class="fw-frame-wrap" style="position:absolute!important;inset:${frameInset}!important;z-index:1!important;${framePadding}">
<iframe id="xiaobaix-fourth-wall-iframe" class="xiaobaix-iframe" src="${iframePath}" style="width:100%!important;height:100%!important;border:none!important;border-radius:${iframeRadius}!important;box-shadow:0 0 30px rgba(0,0,0,.4)!important;background:#1a1a2e!important;"></iframe>
</div>
</div>
`);
@@ -1035,9 +1078,7 @@ function showOverlay() {
function hideOverlay() {
$('#xiaobaix-fourth-wall-overlay').hide();
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
isFullscreen = false;
}
@@ -1058,7 +1099,9 @@ function toggleFullscreen() {
}
}
// ================== 悬浮按钮 ==================
// ════════════════════════════════════════════════════════════════════════════
// 悬浮按钮
// ════════════════════════════════════════════════════════════════════════════
function createFloatingButton() {
if (document.getElementById('xiaobaix-fw-float-btn')) return;
@@ -1068,12 +1111,8 @@ function createFloatingButton() {
const margin = 8;
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
const readPos = () => {
try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; }
};
const writePos = (pos) => {
try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {}
};
const readPos = () => { try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; } };
const writePos = (pos) => { try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {} };
const calcDockLeft = (side, w) => (side === 'left' ? -Math.round(w / 2) : (window.innerWidth - Math.round(w / 2)));
const applyDocked = (side, topRatio) => {
const btn = document.getElementById('xiaobaix-fw-float-btn');
@@ -1087,27 +1126,7 @@ function createFloatingButton() {
};
const $btn = $(`
<button id="xiaobaix-fw-float-btn" title="皮下交流" style="
position: fixed !important;
left: 0px !important;
top: 0px !important;
z-index: 9999 !important;
width: ${size}px !important;
height: ${size}px !important;
border-radius: 50% !important;
border: none !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: #fff !important;
font-size: ${Math.round(size * 0.45)}px !important;
cursor: pointer !important;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: left 0.2s, top 0.2s, transform 0.2s, box-shadow 0.2s !important;
touch-action: none !important;
user-select: none !important;
">
<button id="xiaobaix-fw-float-btn" title="皮下交流" style="position:fixed!important;left:0px!important;top:0px!important;z-index:9999!important;width:${size}px!important;height:${size}px!important;border-radius:50%!important;border:none!important;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)!important;color:#fff!important;font-size:${Math.round(size * 0.45)}px!important;cursor:pointer!important;box-shadow:0 4px 15px rgba(102,126,234,0.4)!important;display:flex!important;align-items:center!important;justify-content:center!important;transition:left 0.2s,top 0.2s,transform 0.2s,box-shadow 0.2s!important;touch-action:none!important;user-select:none!important;">
<i class="fa-solid fa-comments"></i>
</button>
`);
@@ -1118,19 +1137,8 @@ function createFloatingButton() {
showOverlay();
});
$btn.on('mouseenter', function() {
$(this).css({
'transform': 'scale(1.08)',
'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)'
});
});
$btn.on('mouseleave', function() {
$(this).css({
'transform': 'none',
'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)'
});
});
$btn.on('mouseenter', function() { $(this).css({ 'transform': 'scale(1.08)', 'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)' }); });
$btn.on('mouseleave', function() { $(this).css({ 'transform': 'none', 'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)' }); });
document.body.appendChild($btn[0]);
@@ -1138,11 +1146,7 @@ function createFloatingButton() {
applyDocked(initial?.side || 'right', Number.isFinite(initial?.topRatio) ? initial.topRatio : 0.5);
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let pointerId = null;
let startX = 0, startY = 0, startLeft = 0, startTop = 0, pointerId = null;
const onPointerDown = (e) => {
if (e.button !== undefined && e.button !== 0) return;
@@ -1150,10 +1154,7 @@ function createFloatingButton() {
pointerId = e.pointerId;
try { btn.setPointerCapture(pointerId); } catch {}
const rect = btn.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
dragging = false;
btn.style.transition = 'none';
};
@@ -1216,7 +1217,9 @@ function removeFloatingButton() {
}
}
// ================== 初始化和清理 ==================
// ════════════════════════════════════════════════════════════════════════════
// 初始化和清理
// ════════════════════════════════════════════════════════════════════════════
function initFourthWall() {
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {}
@@ -1225,14 +1228,13 @@ function initFourthWall() {
createFloatingButton();
initCommentary();
clearExpiredFWImageCache();
events.on(event_types.CHAT_CHANGED, () => {
cancelGeneration();
currentLoadedChatId = null;
pendingFrameMessages = [];
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) {
hideOverlay();
}
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) hideOverlay();
});
}
@@ -1262,4 +1264,4 @@ if (typeof window !== 'undefined') {
try { fourthWallCleanup(); } catch {}
}
});
}
}