Add files via upload
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function renderThinking(text) {
|
||||
return String(text || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^[\\*\\-]\\s+/gm, '• ')
|
||||
.replace(/\n/g, '<br>');
|
||||
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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>
|
||||
@@ -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风格的tag,5-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 {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user