Files

1327 lines
61 KiB
HTML
Raw Permalink Normal View History

2026-01-17 16:34:39 +08:00
<!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';
2026-01-17 16:34:39 +08:00
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>