Files
LittleWhiteBox/modules/fourth-wall/fourth-wall.html

1327 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>皮下交流</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #f5f6f7;
--bg-secondary: #ffffff;
--bg-tertiary: #f0f2f4;
--bg-bubble-user: #4c9aff;
--bg-bubble-ai: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--text-muted: #9ca3af;
--border-color: rgba(17, 24, 39, 0.12);
--accent: #4c9aff;
--accent-hover: #2f7eea;
--shadow: 0 10px 30px rgba(0,0,0,0.08);
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
.fw-container { display: flex; flex-direction: column; height: 100%; }
.fw-header {
padding: 8px 12px;
background: radial-gradient(1200px 700px at 0% 0%, rgba(76,154,255,0.08) 0%, rgba(76,154,255,0) 55%),
radial-gradient(1000px 600px at 100% 30%, rgba(16,185,129,0.06) 0%, rgba(16,185,129,0) 55%),
var(--bg-primary);
flex-shrink: 0;
}
.fw-header-row { display: flex; justify-content: space-between; align-items: center; }
.fw-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
position: absolute;
left: 50%;
transform: translateX(-50%);
transition: opacity 0.2s;
}
.fw-title.hidden { opacity: 0; pointer-events: none; }
.fw-header-right { display: flex; align-items: center; gap: 6px; }
.fw-back-btn, .fw-menu-toggle {
width: 32px; height: 32px; border: none; background: transparent;
color: var(--text-secondary); cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 18px; transition: color 0.2s;
}
.fw-back-btn:hover, .fw-menu-toggle:hover { color: var(--accent); }
.fw-menu-toggle.expanded { color: var(--accent); }
.fw-header-actions { display: none; gap: 6px; align-items: center; }
.fw-header-actions.visible { display: flex; }
.fw-btn {
padding: 5px 10px; background: var(--bg-secondary); color: var(--text-primary);
border: 1px solid var(--border-color); border-radius: 6px; font-size: 0.75rem;
cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 5px;
}
.fw-btn:hover { background: rgba(76,154,255,0.12); border-color: rgba(76,154,255,0.35); }
.fw-btn-icon { padding: 5px 7px; }
.fw-btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.fw-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.fw-btn-danger { background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.3); color: #f87171; }
.fw-btn-danger:hover { background: rgba(239,68,68,0.25); }
.fw-settings { display: none; padding: 10px 12px 12px; flex-direction: column; gap: 10px; }
.fw-settings.open { display: flex; }
.fw-settings-card {
background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 14px;
box-shadow: var(--shadow); padding: 10px 12px 12px; max-height: min(52vh, 420px); overflow: auto;
}
.fw-settings-header {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 8px; border-bottom: 1px solid var(--border-color); margin-bottom: 2px;
position: sticky; top: 0; background: var(--bg-secondary); z-index: 1;
}
.fw-settings-header h4 { font-size: 0.8125rem; font-weight: 600; color: var(--text-secondary); }
.fw-settings-close {
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border-color);
background: transparent; color: var(--text-secondary); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 11px; transition: all 0.2s;
}
.fw-settings-close:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.fw-settings-group { padding-top: 10px; }
.fw-settings-group:first-of-type { padding-top: 6px; }
.fw-settings-group + .fw-settings-group { border-top: 1px dashed rgba(17, 24, 39, 0.12); }
.fw-settings-group-title {
display: flex; align-items: center; gap: 8px; margin: 8px 2px 6px; padding: 6px 0 2px;
font-size: 0.75rem; font-weight: 600; color: var(--text-muted);
}
.fw-settings-row {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 6px 10px;
background: var(--bg-tertiary); border: 1px solid rgba(17, 24, 39, 0.10); border-radius: 12px;
}
.fw-field { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; }
.fw-field label { color: var(--text-secondary); white-space: nowrap; }
.fw-field select, .fw-field input[type="number"] {
padding: 2px 6px; background: var(--bg-tertiary); border: 1px solid var(--border-color);
border-radius: 4px; color: var(--text-primary); font-size: 0.75rem; height: 24px;
}
.fw-field input[type="number"] { width: 60px; }
.fw-field input[type="checkbox"] { width: 14px; height: 14px; accent-color: var(--accent); }
.fw-field input[type="range"] { accent-color: var(--accent); height: 18px; margin: 0; }
.fw-field .speed-val, .fw-field .prob-val { min-width: 32px; text-align: center; font-size: 0.7rem; color: var(--text-muted); }
.fw-session-manager { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; }
.fw-session-manager select { min-width: 120px; }
.fw-messages {
flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px;
background: radial-gradient(1200px 700px at 0% 0%, rgba(76,154,255,0.08) 0%, rgba(76,154,255,0) 55%),
radial-gradient(1000px 600px at 100% 30%, rgba(16,185,129,0.06) 0%, rgba(16,185,129,0) 55%),
var(--bg-primary);
}
.fw-messages::-webkit-scrollbar { width: 6px; }
.fw-messages::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
.fw-row { display: flex; align-items: flex-end; gap: 10px; max-width: 85%; }
.fw-row.user { align-self: flex-end; flex-direction: row-reverse; }
.fw-row.assistant { align-self: flex-start; }
.fw-avatar {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 14px;
border: 1px solid var(--border-color); background-size: cover;
background-position: center; background-repeat: no-repeat; box-shadow: 0 2px 10px rgba(0,0,0,0.06);
}
.fw-avatar.user-avatar { background-image: var(--fw-user-avatar, none), linear-gradient(135deg, #4c9aff, #2f7eea); background-color: #4c9aff; }
.fw-avatar.char-avatar { background-image: var(--fw-char-avatar, none), linear-gradient(135deg, #f59e0b, #ef4444); background-color: #f59e0b; }
.fw-bubble {
position: relative; padding: 10px 14px; border-radius: 16px;
font-size: 0.9375rem; line-height: 1.6; word-break: break-word;
}
.fw-bubble.user {
background: var(--bg-bubble-user); color: #fff;
border-bottom-right-radius: 4px; box-shadow: 0 10px 24px rgba(76,154,255,0.25);
}
.fw-bubble.assistant {
background: var(--bg-bubble-ai); color: var(--text-primary);
border-bottom-left-radius: 4px; border: 1px solid rgba(17,24,39,0.08); box-shadow: var(--shadow);
}
.fw-bubble-actions { position: absolute; top: -6px; right: -6px; display: none; gap: 4px; }
.fw-row:hover .fw-bubble-actions { display: flex; }
.fw-bubble-btn {
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border-color);
background: var(--bg-secondary); color: var(--text-secondary);
display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 10px;
}
.fw-bubble-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.fw-bubble-time { font-size: 0.6875rem; color: var(--text-muted); margin-top: 4px; }
.fw-row.user .fw-bubble-time { text-align: right; }
.fw-row.assistant .fw-bubble-time { text-align: left; }
.fw-edit-area {
width: 100%; min-height: 60px; padding: 8px; background: var(--bg-tertiary);
border: 1px solid var(--border-color); border-radius: 8px;
color: var(--text-primary); font-size: 0.875rem; resize: vertical;
}
.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; 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: 8px;
padding: 5px 10px;
background: #95ec69;
border-radius: 4px;
cursor: pointer;
user-select: none;
min-width: 80px;
max-width: 200px;
margin: 4px 0;
transition: filter 0.15s;
}
.fw-voice-bubble:hover { filter: brightness(0.95); }
.fw-voice-bubble:active { filter: brightness(0.9); }
.fw-voice-waves {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
width: 20px;
height: 18px;
flex-shrink: 0;
}
.fw-voice-bar {
width: 3px;
background: #fff;
border-radius: 1.5px;
opacity: 0.9;
}
.fw-voice-bar:nth-child(1) { height: 6px; }
.fw-voice-bar:nth-child(2) { height: 10px; }
.fw-voice-bar:nth-child(3) { height: 14px; }
.fw-voice-bubble.playing .fw-voice-bar {
animation: fw-wechat-wave 1.2s infinite ease-in-out;
}
.fw-voice-bubble.playing .fw-voice-bar:nth-child(1) { animation-delay: 0s; }
.fw-voice-bubble.playing .fw-voice-bar:nth-child(2) { animation-delay: 0.2s; }
.fw-voice-bubble.playing .fw-voice-bar:nth-child(3) { animation-delay: 0.4s; }
@keyframes fw-wechat-wave {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.fw-voice-duration {
font-size: 14px;
color: #000;
opacity: 0.7;
margin-left: auto;
}
.fw-voice-bubble.loading { opacity: 0.7; }
.fw-voice-bubble.loading .fw-voice-waves { animation: fw-voice-pulse 1s infinite; }
@keyframes fw-voice-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
.fw-voice-bubble.error { background: #ffb3b3 !important; }
/* 用户消息中的语音(白色背景) */
.fw-row.user .fw-voice-bubble { background: #fff; }
.fw-row.user .fw-voice-bar { background: #b2b2b2; }
.fw-row.user .fw-voice-duration { color: #555; }
.fw-input-area { padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border-color); flex-shrink: 0; }
.fw-input-row { display: flex; gap: 10px; align-items: flex-end; }
.fw-textarea {
flex: 1; padding: 10px 14px; background: #ffffff; border: 1px solid var(--border-color);
border-radius: 20px; color: var(--text-primary); font-size: 0.9375rem;
resize: none; max-height: 120px; line-height: 1.5; overflow-y: auto;
-ms-overflow-style: none; scrollbar-width: none;
}
.fw-textarea::-webkit-scrollbar { display: none; }
.fw-textarea:focus { outline: none; border-color: rgba(76,154,255,0.7); box-shadow: 0 0 0 3px rgba(76,154,255,0.12); }
.fw-send-btn, .fw-regen-btn {
width: 40px; height: 40px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center; cursor: pointer;
font-size: 16px; transition: all 0.2s;
}
.fw-send-btn { background: var(--accent); color: #fff; }
.fw-send-btn:hover { background: var(--accent-hover); }
.fw-regen-btn { background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border-color); }
.fw-regen-btn:hover { background: var(--accent); color: #fff; }
.fw-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);
display: none; align-items: center; justify-content: center; z-index: 1000;
}
.fw-modal-overlay.open { display: flex; }
.fw-modal {
width: 90%; max-width: 700px; max-height: 85vh; background: var(--bg-secondary);
border: 1px solid var(--border-color); border-radius: 12px;
display: flex; flex-direction: column; overflow: hidden;
}
.fw-modal-header {
padding: 14px 16px; border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.fw-modal-header h3 { font-size: 1rem; font-weight: 600; }
.fw-modal-close {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--border-color);
background: transparent; color: var(--text-secondary); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.fw-modal-close:hover { background: var(--accent); color: #fff; }
.fw-modal-body { flex: 1; overflow-y: auto; padding: 16px; }
.fw-modal-footer {
padding: 12px 16px; border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 10px;
}
.fw-prompt-section { margin-bottom: 16px; }
.fw-prompt-section:last-child { margin-bottom: 0; }
.fw-prompt-label { font-size: 0.8125rem; font-weight: 600; margin-bottom: 6px; color: var(--text-secondary); }
.fw-prompt-textarea {
width: 100%; min-height: 100px; padding: 10px; background: var(--bg-tertiary);
border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary);
font-family: 'SF Mono', Monaco, Consolas, monospace; font-size: 0.8125rem; resize: vertical;
}
.fw-prompt-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
.fw-thinking-card {
margin-bottom: 6px; background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px; overflow: hidden; max-width: 100%;
}
.fw-thinking-header {
display: flex; align-items: center; gap: 6px; padding: 8px 12px; cursor: pointer;
font-size: 0.75rem; color: var(--text-muted); transition: background 0.2s, color 0.2s; user-select: none;
}
.fw-thinking-header:hover { background: rgba(0,0,0,0.04); color: var(--text-secondary); }
.fw-thinking-header .chevron { font-size: 10px; transition: transform 0.2s; }
.fw-thinking-header.expanded .chevron { transform: rotate(90deg); }
.fw-thinking-body {
display: none; padding: 0 12px 10px; font-size: 0.8125rem; line-height: 1.6;
color: var(--text-secondary); word-break: break-word; max-height: 300px; overflow-y: auto;
}
.fw-thinking-body.expanded { display: block; animation: thinkingFadeIn 0.2s ease; }
@keyframes thinkingFadeIn { from { opacity: 0; } to { opacity: 1; } }
.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; width: 4px; height: 4px; margin-left: 6px;
background: var(--accent); border-radius: 50%; animation: thinkingPulse 1s infinite;
}
@keyframes thinkingPulse { 0%, 100% { opacity: 0.4; } 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; }
@media (max-width: 600px) {
.fw-header { padding: 6px 12px; }
.fw-settings { padding: 8px 10px 10px; }
.fw-settings-card { padding: 10px; border-radius: 12px; max-height: min(56vh, 520px); }
.fw-messages { padding: 12px; }
.fw-input-area { padding: 10px 12px; }
.fw-row { max-width: 90%; }
.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>
<body>
<div class="fw-container">
<header class="fw-header">
<div class="fw-header-row">
<button class="fw-back-btn" id="btn-close" title="返回"><i class="fa-solid fa-chevron-left"></i></button>
<span class="fw-title" id="fw-title">皮下交流</span>
<div class="fw-header-right">
<div class="fw-header-actions" id="header-actions">
<button class="fw-btn fw-btn-icon" id="btn-fullscreen" title="全屏"><i class="fa-solid fa-expand"></i></button>
<button class="fw-btn fw-btn-icon" id="btn-settings" title="设置"><i class="fa-solid fa-gear"></i></button>
<button class="fw-btn fw-btn-icon" id="btn-prompt" title="提示词"><i class="fa-solid fa-file-lines"></i></button>
<button class="fw-btn fw-btn-danger" id="btn-reset">重开</button>
</div>
<button class="fw-menu-toggle" id="btn-menu-toggle" title="菜单"><i class="fa-solid fa-bars"></i></button>
</div>
</div>
</header>
<div class="fw-settings" id="settings-panel">
<div class="fw-settings-card">
<div class="fw-settings-header">
<h4>设置</h4>
<button class="fw-settings-close" id="btn-settings-close" title="关闭设置"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="fw-settings-group-title"><i class="fa-solid fa-folder-open"></i>会话</div>
<div class="fw-settings-row">
<div class="fw-session-manager">
<label>记录</label>
<select id="session-select"></select>
<button class="fw-btn fw-btn-icon" id="session-add" title="新建"><i class="fa-solid fa-plus"></i></button>
<button class="fw-btn fw-btn-icon" id="session-rename" title="重命名"><i class="fa-solid fa-edit"></i></button>
<button class="fw-btn fw-btn-icon fw-btn-danger" id="session-delete" title="删除"><i class="fa-solid fa-trash"></i></button>
</div>
</div>
<div class="fw-settings-group-title"><i class="fa-solid fa-sliders"></i>生成</div>
<div class="fw-settings-row">
<div class="fw-field">
<label>历史楼层</label>
<input type="number" id="layers-input" value="9999" min="1" max="9999">
</div>
<div class="fw-field">
<label>记忆上限</label>
<input type="number" id="turns-input" value="9999" min="1" max="9999">
</div>
<div class="fw-field">
<input type="checkbox" id="stream-enabled" checked>
<label for="stream-enabled">流式传输</label>
</div>
</div>
<div class="fw-settings-group-title"><i class="fa-solid fa-photo-film"></i>媒体</div>
<div class="fw-settings-row">
<div class="fw-field">
<input type="checkbox" id="img-prompt-enabled">
<label for="img-prompt-enabled">允许发图 (需开启插件NovelAI画图)</label>
</div>
</div>
<div class="fw-settings-row">
<div class="fw-field">
<input type="checkbox" id="voice-enabled">
<label for="voice-enabled">允许语音</label>
</div>
<div class="fw-field fw-voice-select-wrap" style="display: none;">
<label>声音</label>
<select id="voice-select"></select>
</div>
<div class="fw-field fw-voice-speed-wrap" style="display: none;">
<label>语速</label>
<input type="range" id="voice-speed" min="0.5" max="2.0" step="0.1" value="1.0" style="width:70px;">
<span class="speed-val" id="voice-speed-val">1.0x</span>
</div>
</div>
<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">
<label for="commentary-enabled">实时吐槽</label>
</div>
<div class="fw-field fw-commentary-prob-wrap">
<label>概率</label>
<input type="range" id="commentary-prob" min="1" max="99" value="30" style="width:70px;">
<span class="prob-val" id="commentary-prob-val">30%</span>
</div>
</div>
</div>
</div>
<div class="fw-messages" id="messages"></div>
<div class="fw-input-area">
<div class="fw-input-row">
<textarea class="fw-textarea" id="input-textarea" rows="1" placeholder="和TA皮下聊点什么..."></textarea>
<button class="fw-regen-btn" id="btn-regen" title="重答"><i class="fa-solid fa-arrows-rotate"></i></button>
<button class="fw-send-btn" id="btn-send" title="发送"><i class="fa-solid fa-paper-plane"></i></button>
</div>
</div>
</div>
<div class="fw-modal-overlay" id="prompt-modal">
<div class="fw-modal">
<div class="fw-modal-header">
<h3>提示词设置</h3>
<button class="fw-modal-close" id="prompt-close"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="fw-modal-body">
<div class="fw-prompt-section">
<div class="fw-prompt-label">顶部提示词 (User)</div>
<textarea class="fw-prompt-textarea" id="prompt-topuser" rows="4"></textarea>
</div>
<div class="fw-prompt-section">
<div class="fw-prompt-label">确认消息 (Assistant)</div>
<textarea class="fw-prompt-textarea" id="prompt-confirm" rows="2"></textarea>
<div class="fw-prompt-hint">模型确认理解任务的回复,作为第二条消息</div>
</div>
<div class="fw-prompt-section">
<div class="fw-prompt-label">扮演需求 (User)</div>
<textarea class="fw-prompt-textarea" id="prompt-meta" rows="10"></textarea>
<div class="fw-prompt-hint">可用变量:{{USER_NAME}}、{{CHAR_NAME}}</div>
</div>
<div class="fw-prompt-section">
<div class="fw-prompt-label">底部提示词 (Assistant)</div>
<textarea class="fw-prompt-textarea" id="prompt-bottom" rows="4"></textarea>
<div class="fw-prompt-hint">可用变量:{{USER_INPUT}}</div>
</div>
</div>
<div class="fw-modal-footer">
<button class="fw-btn" id="prompt-cancel">取消</button>
<button class="fw-btn fw-btn-danger" id="prompt-restore-default">恢复默认</button>
<button class="fw-btn fw-btn-primary" id="prompt-save">保存</button>
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
/* ══════════════════════════════════════════════════════════════════════════════
配置
══════════════════════════════════════════════════════════════════════════════ */
const TTS_WORKER_URL = 'https://hstts.velure.codes';
const VALID_EMOTIONS = ['happy', 'sad', 'angry', 'surprise', 'scare', 'hate'];
const EMOTION_ICONS = {
happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢'
};
// 动态加载的声音列表
let voiceList = [];
let defaultVoiceKey = 'female_1';
/* ══════════════════════════════════════════════════════════════════════════════
工具函数
══════════════════════════════════════════════════════════════════════════════ */
function escapeHtml(text) {
return escapeHtmlText(text).replace(/\n/g, '<br>');
}
function escapeHtmlText(text) {
return String(text || '').replace(/[&<>\"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', '\'': '&#39;' }[c]));
}
function renderThinking(text) {
return escapeHtmlText(text)
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/^[\\*\\-]\\s+/gm, '• ').replace(/\n/g, '<br>');
}
function formatTimeDisplay(ts) {
if (!ts) return '';
const date = new Date(ts);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 86400000);
const msgDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const pad = n => String(n).padStart(2, '0');
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
if (msgDate.getTime() === today.getTime()) return time;
if (msgDate.getTime() === yesterday.getTime()) return `昨天 ${time}`;
return `${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${time}`;
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
function postToParent(payload) {
window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, PARENT_ORIGIN);
}
function getEmotionIcon(emotion) {
return EMOTION_ICONS[emotion] || '';
}
/* ══════════════════════════════════════════════════════════════════════════════
状态管理
══════════════════════════════════════════════════════════════════════════════ */
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: 'female_1', speed: 1.0 },
commentarySettings: { enabled: false, probability: 30 },
promptTemplates: {}
};
let currentAudio = null;
/* ══════════════════════════════════════════════════════════════════════════════
加载声音列表
══════════════════════════════════════════════════════════════════════════════ */
async function loadVoices() {
try {
const res = await fetch(`${TTS_WORKER_URL}/voices`);
if (!res.ok) throw new Error('Failed to load voices');
const data = await res.json();
voiceList = data.voices || [];
defaultVoiceKey = data.defaultVoice || 'female_1';
renderVoiceSelect();
} catch (err) {
console.error('[FW Voice] 加载声音列表失败:', err);
// 降级:使用空列表
voiceList = [];
}
}
/* ══════════════════════════════════════════════════════════════════════════════
图片懒加载(保持不变)
══════════════════════════════════════════════════════════════════════════════ */
const pendingImages = new Map();
const generatingQueue = new Set();
let imageObserver = null;
function parseImageToken(rawCSV) {
let txt = String(rawCSV || '').trim();
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, 'nsfw, ');
return txt.split(',').map(s => s.trim()).filter(Boolean).join(', ');
}
function 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 });
}
});
}, { 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 handleImageProgress(data) {
const pending = pendingImages.get(data.requestId);
if (!pending) return;
const { slot } = pending;
if (!slot) return;
switch (data.status) {
case 'queued':
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${data.position}</div>`;
break;
case 'generating':
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-palette"></i> 生成中${data.position > 0 ? ` (${data.position} 排队)` : ''}...</div>`;
break;
case 'waiting':
slot.innerHTML = `<div class="fw-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${data.position} (${data.delay}s)</div>`;
break;
}
}
function bindRetryButton(slot) {
const btn = slot.querySelector('.fw-img-retry');
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, emotion, bubbleEl) {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
document.querySelectorAll('.fw-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
}
bubbleEl.classList.add('loading');
bubbleEl.classList.remove('error');
try {
const requestBody = {
voiceKey: state.voiceSettings.voice || defaultVoiceKey,
text: text,
speed: state.voiceSettings.speed || 1.0,
uid: 'fw_' + Date.now(),
reqid: generateUUID()
};
if (emotion && VALID_EMOTIONS.includes(emotion)) {
requestBody.emotion = emotion;
requestBody.emotionScale = 5;
}
const res = await fetch(TTS_WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.code !== 3000) throw new Error(data.message || 'TTS失败');
bubbleEl.classList.remove('loading');
bubbleEl.classList.add('playing');
currentAudio = new Audio(`data:audio/mp3;base64,${data.data}`);
currentAudio.onended = () => { bubbleEl.classList.remove('playing'); currentAudio = null; };
currentAudio.onerror = () => { bubbleEl.classList.remove('playing'); bubbleEl.classList.add('error'); currentAudio = null; };
await currentAudio.play();
} catch (err) {
console.error('[FW Voice] TTS错误:', err);
bubbleEl.classList.remove('loading', 'playing');
bubbleEl.classList.add('error');
setTimeout(() => bubbleEl.classList.remove('error'), 3000);
}
}
function hydrateVoiceSlots(container) {
container.querySelectorAll('.fw-voice-bubble:not([data-bound])').forEach(bubble => {
bubble.setAttribute('data-bound', '1');
const text = decodeURIComponent(bubble.dataset.text || '');
const emotion = bubble.dataset.emotion || '';
if (text) {
bubble.onclick = e => {
e.stopPropagation();
if (bubble.classList.contains('loading')) return;
if (bubble.classList.contains('playing') && currentAudio) {
currentAudio.pause();
currentAudio = null;
bubble.classList.remove('playing');
} else {
playVoice(text, emotion, bubble);
}
};
}
});
}
/* ══════════════════════════════════════════════════════════════════════════════
内容渲染
══════════════════════════════════════════════════════════════════════════════ */
function renderContent(text) {
if (!text) return '';
let html = escapeHtmlText(text);
html = html.replace(/\[(?:img|图片)\s*:\s*([^\]]+)\]/gi, (_, inner) => {
const tags = parseImageToken(inner);
if (!tags) return _;
return `<div class="fw-img-slot" data-raw="${encodeURIComponent(inner)}"></div>`;
});
html = html.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (_, emotionRaw, voiceText) => {
const emotion = (emotionRaw || '').trim().toLowerCase();
const txt = voiceText.trim();
if (!txt) return _;
const duration = Math.max(2, Math.ceil(txt.length / 4));
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="${emotion}">
<div class="fw-voice-waves">
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
</div>
<span class="fw-voice-duration">${duration}"</span>
</div>`;
});
html = html.replace(/\[(?:voice|语音)\s*:\s*([^\]]+)\]/gi, (_, voiceText) => {
const txt = voiceText.trim();
if (!txt) return _;
const duration = Math.max(2, Math.ceil(txt.length / 4));
return `<div class="fw-voice-bubble" data-text="${encodeURIComponent(txt)}" data-emotion="">
<div class="fw-voice-waves">
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
<div class="fw-voice-bar"></div>
</div>
<span class="fw-voice-duration">${duration}"</span>
</div>`;
});
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
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;
}
function renderMessages() {
const container = document.getElementById('messages');
const { history, isStreaming, editingIndex } = state;
if (!history.length && !isStreaming) {
container.innerHTML = '<div class="fw-empty">开始与TA皮下交流吧~</div>';
return;
}
let html = history.map((msg, idx) => {
const isUser = msg.role === 'user';
const isEditing = editingIndex === idx;
const timeStr = formatTimeDisplay(msg.ts);
const bubbleContent = isEditing
? `<textarea class="fw-edit-area" data-index="${idx}">${escapeHtmlText(msg.content || '')}</textarea>`
: renderContent(msg.content);
const actions = isEditing
? `<div class="fw-bubble-actions" style="display:flex;"><button class="fw-bubble-btn fw-save-btn" data-index="${idx}" title="保存"><i class="fa-solid fa-check"></i></button><button class="fw-bubble-btn fw-cancel-btn" data-index="${idx}" title="取消"><i class="fa-solid fa-xmark"></i></button></div>`
: `<div class="fw-bubble-actions"><button class="fw-bubble-btn fw-edit-btn" data-index="${idx}" title="编辑"><i class="fa-solid fa-pen"></i></button><button class="fw-bubble-btn fw-delete-btn" data-index="${idx}" title="删除"><i class="fa-solid fa-trash"></i></button></div>`;
let thinkingHtml = '';
if (!isUser && msg.thinking) {
thinkingHtml = `<div class="fw-thinking-card"><div class="fw-thinking-header" data-index="${idx}"><i class="fa-solid fa-chevron-right chevron"></i><span>思考过程</span></div><div class="fw-thinking-body" data-index="${idx}">${renderThinking(msg.thinking)}</div></div>`;
}
return `<div class="fw-row ${isUser ? 'user' : 'assistant'}${isEditing ? ' editing' : ''}">
<div class="fw-avatar ${isUser ? 'user-avatar' : 'char-avatar'}"></div>
<div style="display:flex;flex-direction:column;align-items:${isUser ? 'flex-end' : 'flex-start'};">
${thinkingHtml}
<div class="fw-bubble ${isUser ? 'user' : 'assistant'}">${actions}${bubbleContent}</div>
${timeStr ? `<div class="fw-bubble-time">${timeStr}</div>` : ''}
</div>
</div>`;
}).join('');
if (isStreaming) {
html += `<div class="fw-row assistant">
<div class="fw-avatar char-avatar"></div>
<div style="display:flex;flex-direction:column;align-items:flex-start;">
<div class="fw-thinking-card" id="streaming-thinking-card" style="display:none;">
<div class="fw-thinking-header expanded streaming"><i class="fa-solid fa-chevron-right chevron"></i><span>思考中</span></div>
<div class="fw-thinking-body expanded" id="streaming-thinking"></div>
</div>
<div class="fw-bubble assistant fw-streaming" id="streaming-bubble">(等待回应...)</div>
</div>
</div>`;
}
container.innerHTML = html;
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;
header.classList.toggle('expanded');
container.querySelector(`.fw-thinking-body[data-index="${idx}"]`)?.classList.toggle('expanded');
};
});
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(); }
};
});
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 });
};
});
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 });
}
};
});
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>`
).join('');
}
// 使用动态加载的声音列表渲染下拉框
function renderVoiceSelect() {
const select = document.getElementById('voice-select');
if (!select || !voiceList.length) return;
const females = voiceList.filter(v => v.gender === 'female');
const males = voiceList.filter(v => v.gender === 'male');
select.innerHTML = `
<optgroup label="👩 女声">
${females.map(v => `<option value="${v.key}">${v.name} · ${v.tag}</option>`).join('')}
</optgroup>
<optgroup label="👨 男声">
${males.map(v => `<option value="${v.key}">${v.name} · ${v.tag}</option>`).join('')}
</optgroup>
`;
select.value = state.voiceSettings.voice || defaultVoiceKey;
}
function updateMenuUI() {
const actions = document.getElementById('header-actions');
const toggle = document.getElementById('btn-menu-toggle');
const title = document.getElementById('fw-title');
if (state.menuExpanded) {
actions.classList.add('visible');
toggle.classList.add('expanded');
title.classList.add('hidden');
} else {
actions.classList.remove('visible');
toggle.classList.remove('expanded');
title.classList.remove('hidden');
document.getElementById('settings-panel').classList.remove('open');
}
}
function updateVoiceUI(enabled) {
document.querySelector('.fw-voice-select-wrap').style.display = enabled ? '' : 'none';
document.querySelector('.fw-voice-speed-wrap').style.display = enabled ? '' : 'none';
}
function updateCommentaryUI(enabled) {
document.querySelector('.fw-commentary-prob-wrap').style.display = enabled ? '' : 'none';
}
function updateSendButton() {
const btn = document.getElementById('btn-send');
btn.innerHTML = state.isStreaming ? '<i class="fa-solid fa-stop"></i>' : '<i class="fa-solid fa-paper-plane"></i>';
btn.title = state.isStreaming ? '停止' : '发送';
}
function updateFullscreenButton(isFullscreen) {
const btn = document.getElementById('btn-fullscreen');
if (!btn) return;
const icon = btn.querySelector('i');
if (!icon) return;
icon.className = isFullscreen ? 'fa-solid fa-compress' : 'fa-solid fa-expand';
btn.title = isFullscreen ? '退出全屏' : '全屏';
}
function loadPromptFields() {
const t = state.promptTemplates || {};
document.getElementById('prompt-topuser').value = t.topuser || '';
document.getElementById('prompt-confirm').value = t.confirm || '';
document.getElementById('prompt-meta').value = t.metaProtocol || '';
document.getElementById('prompt-bottom').value = t.bottom || '';
}
/* ══════════════════════════════════════════════════════════════════════════════
消息发送
══════════════════════════════════════════════════════════════════════════════ */
function sendMessage() {
const textarea = document.getElementById('input-textarea');
const text = textarea.value.trim();
if (!text || state.isStreaming) return;
state.history.push({ role: 'user', content: text, ts: Date.now() });
state.isStreaming = true;
textarea.value = '';
textarea.style.height = 'auto';
renderMessages();
updateSendButton();
postToParent({ type: 'SEND_MESSAGE', userInput: text, history: state.history, settings: state.settings, imgSettings: state.imgSettings, voiceSettings: state.voiceSettings });
}
function regenerate() {
if (state.isStreaming || !state.history.length) return;
let lastUserText = null;
for (let i = state.history.length - 1; i >= 0; i--) {
if (state.history[i].role === 'user') { lastUserText = state.history[i].content; break; }
}
if (!lastUserText) return;
if (state.history[state.history.length - 1].role === 'ai') state.history.pop();
state.isStreaming = true;
renderMessages();
updateSendButton();
postToParent({ type: 'REGENERATE', userInput: lastUserText, history: state.history, settings: state.settings, imgSettings: state.imgSettings, voiceSettings: state.voiceSettings });
}
/* ══════════════════════════════════════════════════════════════════════════════
消息处理
══════════════════════════════════════════════════════════════════════════════ */
// Guarded by origin/source check.
window.addEventListener('message', event => {
if (event.origin !== PARENT_ORIGIN || event.source !== window.parent) return;
const data = event.data;
if (!data || data.source !== 'LittleWhiteBox') return;
if (data.type === 'PING') {
window.parent.postMessage({
source: 'LittleWhiteBox-FourthWall',
type: 'PONG',
pingId: data.pingId
}, PARENT_ORIGIN);
return;
}
switch (data.type) {
case 'INIT_DATA':
state.settings = data.settings || state.settings;
state.sessions = data.sessions || [];
state.activeSessionId = data.activeSessionId;
state.history = data.history || [];
state.imgSettings = data.imgSettings || state.imgSettings;
state.voiceSettings = data.voiceSettings || state.voiceSettings;
state.promptTemplates = data.promptTemplates || state.promptTemplates;
state.commentarySettings = data.commentarySettings || state.commentarySettings;
if (data.avatars) {
document.documentElement.style.setProperty('--fw-user-avatar', data.avatars.user ? `url("${data.avatars.user}")` : 'none');
document.documentElement.style.setProperty('--fw-char-avatar', data.avatars.char ? `url("${data.avatars.char}")` : 'none');
}
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-prompt-enabled').checked = state.imgSettings.enablePrompt;
document.getElementById('voice-enabled').checked = state.voiceSettings.enabled;
// 等声音列表加载完再设置值
if (voiceList.length) {
document.getElementById('voice-select').value = state.voiceSettings.voice || defaultVoiceKey;
}
document.getElementById('voice-speed').value = state.voiceSettings.speed || 1.0;
document.getElementById('voice-speed-val').textContent = (state.voiceSettings.speed || 1.0).toFixed(1) + 'x';
updateVoiceUI(state.voiceSettings.enabled);
document.getElementById('commentary-enabled').checked = state.commentarySettings.enabled;
document.getElementById('commentary-prob').value = state.commentarySettings.probability;
document.getElementById('commentary-prob-val').textContent = state.commentarySettings.probability + '%';
updateCommentaryUI(state.commentarySettings.enabled);
renderSessionSelect();
if (document.getElementById('prompt-modal').classList.contains('open')) loadPromptFields();
renderMessages();
break;
case 'STREAM_UPDATE':
const bubble = document.getElementById('streaming-bubble');
const thinkingCard = document.getElementById('streaming-thinking-card');
const thinkingBody = document.getElementById('streaming-thinking');
if (bubble) {
if (data.thinking && thinkingCard && thinkingBody) {
thinkingCard.style.display = '';
thinkingBody.innerHTML = renderThinking(data.thinking);
}
bubble.innerHTML = renderContent(data.text);
hydrateVoiceSlots(bubble.closest('.fw-messages'));
bubble.closest('.fw-messages').scrollTop = bubble.closest('.fw-messages').scrollHeight;
}
break;
case 'STREAM_COMPLETE':
state.isStreaming = false;
state.history.push({ role: 'ai', content: data.finalText, thinking: data.thinking || undefined, ts: Date.now() });
renderMessages();
updateSendButton();
break;
case 'GENERATION_CANCELLED':
state.isStreaming = false;
renderMessages();
updateSendButton();
break;
case 'FULLSCREEN_STATE':
updateFullscreenButton(data.isFullscreen);
break;
case 'IMAGE_RESULT':
handleImageResult(data);
break;
case 'CACHE_MISS':
handleCacheMiss(data);
break;
case 'IMAGE_PROGRESS':
handleImageProgress(data);
break;
}
});
/* ══════════════════════════════════════════════════════════════════════════════
初始化
══════════════════════════════════════════════════════════════════════════════ */
document.addEventListener('DOMContentLoaded', async () => {
// 先加载声音列表
await loadVoices();
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-reset').onclick = () => {
if (confirm('确定要清空当前对话吗')) { state.history = []; renderMessages(); postToParent({ type: 'RESET_HISTORY' }); }
};
['layers-input', 'turns-input', 'stream-enabled'].forEach(id => {
document.getElementById(id).onchange = () => {
state.settings.maxChatLayers = parseInt(document.getElementById('layers-input').value) || 9999;
state.settings.maxMetaTurns = parseInt(document.getElementById('turns-input').value) || 9999;
state.settings.stream = document.getElementById('stream-enabled').checked;
postToParent({ type: 'SAVE_SETTINGS', settings: state.settings });
};
});
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 });
};
document.getElementById('voice-speed').oninput = function() {
const val = parseFloat(this.value);
document.getElementById('voice-speed-val').textContent = val.toFixed(1) + 'x';
state.voiceSettings.speed = val;
postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings });
};
document.getElementById('commentary-enabled').onchange = function() {
state.commentarySettings.enabled = this.checked;
updateCommentaryUI(this.checked);
postToParent({ type: 'SAVE_COMMENTARY_SETTINGS', commentarySettings: state.commentarySettings });
};
document.getElementById('commentary-prob').oninput = function() {
const val = parseInt(this.value);
document.getElementById('commentary-prob-val').textContent = val + '%';
state.commentarySettings.probability = val;
postToParent({ type: 'SAVE_COMMENTARY_SETTINGS', commentarySettings: state.commentarySettings });
};
document.getElementById('session-select').onchange = e => postToParent({ type: 'SWITCH_SESSION', sessionId: e.target.value });
document.getElementById('session-add').onclick = () => { const name = prompt('新记录名称', '新记录'); if (name) postToParent({ type: 'ADD_SESSION', name: name.trim() }); };
document.getElementById('session-rename').onclick = () => { const current = state.sessions.find(s => s.id === state.activeSessionId); const name = prompt('重命名记录', current?.name || ''); if (name) postToParent({ type: 'RENAME_SESSION', sessionId: state.activeSessionId, name: name.trim() }); };
document.getElementById('session-delete').onclick = () => { if (state.sessions.length <= 1) { alert('至少保留一份记录'); return; } if (confirm('确定删除当前记录吗')) postToParent({ type: 'DELETE_SESSION', sessionId: state.activeSessionId }); };
document.getElementById('btn-prompt').onclick = () => { loadPromptFields(); document.getElementById('prompt-modal').classList.add('open'); };
document.getElementById('prompt-close').onclick = () => document.getElementById('prompt-modal').classList.remove('open');
document.getElementById('prompt-cancel').onclick = () => document.getElementById('prompt-modal').classList.remove('open');
document.getElementById('prompt-restore-default').onclick = () => { if (confirm('确定恢复默认提示词吗')) postToParent({ type: 'RESTORE_DEFAULT_PROMPT_TEMPLATES' }); };
document.getElementById('prompt-save').onclick = () => {
state.promptTemplates = {
topuser: document.getElementById('prompt-topuser').value,
confirm: document.getElementById('prompt-confirm').value,
metaProtocol: document.getElementById('prompt-meta').value,
bottom: document.getElementById('prompt-bottom').value,
imgGuideline: state.promptTemplates?.imgGuideline || ''
};
postToParent({ type: 'SAVE_PROMPT_TEMPLATES', templates: state.promptTemplates });
document.getElementById('prompt-modal').classList.remove('open');
};
const textarea = document.getElementById('input-textarea');
textarea.oninput = function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 120) + 'px'; };
textarea.addEventListener('keydown', function(e) {
if (/Android|iPhone|iPad|iPod|webOS/i.test(navigator.userAgent) || ('ontouchstart' in window && window.innerWidth <= 768)) return;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); state.isStreaming ? postToParent({ type: 'CANCEL_GENERATION' }) : sendMessage(); }
});
document.getElementById('btn-send').onclick = () => { state.isStreaming ? postToParent({ type: 'CANCEL_GENERATION' }) : sendMessage(); };
document.getElementById('btn-regen').onclick = regenerate;
postToParent({ type: 'FRAME_READY' });
});
</script>
</body>
</html>