1327 lines
61 KiB
HTML
1327 lines
61 KiB
HTML
<!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.top';
|
|
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', '\'': ''' }[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>
|