rtc-voice-chat/simple-frontend/example.html
2026-04-02 09:40:23 +08:00

449 lines
20 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>AI 语音对话</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: -apple-system, 'PingFang SC', 'Helvetica Neue', sans-serif; background: #000; color: #fff; }
.app {
width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center;
position: relative; overflow: hidden;
background: radial-gradient(ellipse at 50% 45%, #111827 0%, #070b14 55%, #000 100%);
}
/* 背景微粒噪点 */
.app::before {
content: ''; position: absolute; inset: 0; z-index: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
opacity: 0.5; pointer-events: none;
}
/* 背景柔光 */
.app::after {
content: ''; position: absolute; width: 600px; height: 600px;
top: 30%; left: 50%; transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(16,185,129,0.04) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
/* ============ 启动页 ============ */
.landing { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20; transition: opacity 0.6s ease; }
.landing.fade-out { opacity: 0; pointer-events: none; }
.landing-orb {
width: 88px; height: 88px; border-radius: 50%;
background: radial-gradient(circle at 36% 30%, rgba(167,243,208,0.9), #34d399 40%, #059669 75%, #064e3b 100%);
box-shadow:
0 0 40px rgba(16,185,129,0.25),
0 0 80px rgba(16,185,129,0.08),
inset 0 -4px 12px rgba(0,0,0,0.2),
inset 0 2px 6px rgba(255,255,255,0.15);
cursor: pointer; transition: all 0.3s ease;
}
.landing-orb:hover { transform: scale(1.1); box-shadow: 0 0 60px rgba(16,185,129,0.4), 0 0 120px rgba(16,185,129,0.12), inset 0 -4px 12px rgba(0,0,0,0.2), inset 0 2px 6px rgba(255,255,255,0.15); }
.landing-orb:active { transform: scale(0.95); }
.landing-text { margin-top: 28px; font-size: 17px; color: rgba(255,255,255,0.6); font-weight: 300; letter-spacing: 1px; }
.landing-sub { margin-top: 8px; font-size: 12px; color: rgba(255,255,255,0.2); letter-spacing: 0.5px; }
.landing-start {
margin-top: 40px; padding: 12px 40px; border-radius: 999px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.7); font-size: 14px; cursor: pointer; transition: all 0.25s;
backdrop-filter: blur(10px); letter-spacing: 0.5px;
}
.landing-start:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); color: #fff; }
.landing-start:disabled { opacity: 0.3; cursor: wait; }
/* ============ 通话界面 ============ */
.call-view { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; z-index: 10; opacity: 0; pointer-events: none; transition: opacity 0.6s ease 0.3s; }
.call-view.active { opacity: 1; pointer-events: all; }
/* 顶部字幕区 */
.caption-area {
position: absolute; top: 0; left: 0; right: 0;
max-height: 45%; overflow-y: auto; padding: 60px 28px 20px;
mask-image: linear-gradient(to bottom, transparent 0%, black 60px, black 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 60px, black 100%);
scrollbar-width: none;
}
.caption-area::-webkit-scrollbar { display: none; }
.caption-msg { margin-bottom: 12px; line-height: 1.7; font-weight: 300; }
.caption-msg.user { color: rgba(255,255,255,0.35); font-size: 14px; }
.caption-msg.bot { color: rgba(255,255,255,0.75); font-size: 15px; }
.caption-msg .interrupted-tag {
display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 4px;
background: rgba(251,191,36,0.15); color: #fbbf24; margin-left: 6px; vertical-align: middle;
}
/* ======== 中心光球 ======== */
.orb-container {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 180px; height: 180px; display: flex; align-items: center; justify-content: center;
}
.orb {
width: 90px; height: 90px; border-radius: 50%; position: relative;
background: radial-gradient(circle at 36% 30%, rgba(167,243,208,0.9), #34d399 35%, #059669 65%, #064e3b 100%);
box-shadow:
0 0 30px rgba(16,185,129,0.2),
0 0 60px rgba(16,185,129,0.08),
inset 0 -3px 8px rgba(0,0,0,0.25),
inset 0 2px 4px rgba(255,255,255,0.12);
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 内部高光 */
.orb::before {
content: ''; position: absolute; top: 14%; left: 22%; width: 36%; height: 28%;
border-radius: 50%;
background: radial-gradient(ellipse, rgba(255,255,255,0.35) 0%, transparent 70%);
filter: blur(2px); pointer-events: none;
}
/* 底部反光 */
.orb::after {
content: ''; position: absolute; bottom: -16px; left: 20%; width: 60%; height: 8px;
border-radius: 50%;
background: radial-gradient(ellipse, rgba(16,185,129,0.15) 0%, transparent 70%);
filter: blur(4px); pointer-events: none;
}
/* 外圈脉冲环 */
.orb-ring {
position: absolute; border-radius: 50%;
border: 1px solid rgba(16,185,129,0.1);
animation: none; transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.orb-ring.r1 { inset: calc(50% - 56px); width: 112px; height: 112px; }
.orb-ring.r2 { inset: calc(50% - 72px); width: 144px; height: 144px; border-color: rgba(16,185,129,0.05); }
.orb-ring.r3 { inset: calc(50% - 88px); width: 176px; height: 176px; border-color: rgba(16,185,129,0.025); }
/* 状态动画 */
.orb-container[data-state="idle"] .orb {
animation: idle-breathe 5s ease-in-out infinite;
}
.orb-container[data-state="listening"] .orb {
background: radial-gradient(circle at 36% 30%, rgba(191,219,254,0.9), #60a5fa 35%, #2563eb 65%, #1e3a5f 100%);
box-shadow: 0 0 30px rgba(37,99,235,0.25), 0 0 60px rgba(37,99,235,0.08), inset 0 -3px 8px rgba(0,0,0,0.25), inset 0 2px 4px rgba(255,255,255,0.12);
animation: listen-pulse 2s ease-in-out infinite;
}
.orb-container[data-state="listening"] .orb-ring.r1 { border-color: rgba(37,99,235,0.15); animation: ring-pulse 2.5s ease-in-out infinite; }
.orb-container[data-state="listening"] .orb-ring.r2 { border-color: rgba(37,99,235,0.08); animation: ring-pulse 2.5s ease-in-out infinite 0.4s; }
.orb-container[data-state="listening"] .orb-ring.r3 { border-color: rgba(37,99,235,0.03); animation: ring-pulse 2.5s ease-in-out infinite 0.8s; }
.orb-container[data-state="thinking"] .orb {
background: radial-gradient(circle at 36% 30%, rgba(233,213,255,0.9), #a78bfa 35%, #7c3aed 65%, #4c1d95 100%);
box-shadow: 0 0 30px rgba(124,58,237,0.25), 0 0 60px rgba(124,58,237,0.08), inset 0 -3px 8px rgba(0,0,0,0.25), inset 0 2px 4px rgba(255,255,255,0.12);
animation: think-glow 2s ease-in-out infinite;
}
.orb-container[data-state="thinking"] .orb-ring.r1 { border-color: rgba(124,58,237,0.15); animation: ring-pulse 1.8s ease-in-out infinite; }
.orb-container[data-state="thinking"] .orb-ring.r2 { border-color: rgba(124,58,237,0.08); animation: ring-pulse 1.8s ease-in-out infinite 0.3s; }
.orb-container[data-state="speaking"] .orb {
background: radial-gradient(circle at 36% 30%, rgba(167,243,208,0.95), #34d399 35%, #059669 65%, #064e3b 100%);
box-shadow: 0 0 40px rgba(16,185,129,0.35), 0 0 80px rgba(16,185,129,0.12), inset 0 -3px 8px rgba(0,0,0,0.2), inset 0 2px 4px rgba(255,255,255,0.15);
animation: speak-pulse 0.8s ease-in-out infinite alternate;
}
.orb-container[data-state="speaking"] .orb-ring.r1 { border-color: rgba(16,185,129,0.2); animation: ring-pulse 1.2s ease-in-out infinite; }
.orb-container[data-state="speaking"] .orb-ring.r2 { border-color: rgba(16,185,129,0.1); animation: ring-pulse 1.2s ease-in-out infinite 0.2s; }
.orb-container[data-state="speaking"] .orb-ring.r3 { border-color: rgba(16,185,129,0.05); animation: ring-pulse 1.2s ease-in-out infinite 0.4s; }
@keyframes idle-breathe {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
@keyframes listen-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.06); }
}
@keyframes think-glow {
0%, 100% { transform: scale(1); filter: brightness(1); }
50% { transform: scale(1.04); filter: brightness(1.15) hue-rotate(8deg); }
}
@keyframes speak-pulse {
0% { transform: scale(1); }
100% { transform: scale(1.1); }
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.04); opacity: 0.3; }
}
/* 光球下方状态文字 */
.orb-label {
position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%);
font-size: 12px; color: rgba(255,255,255,0.28); white-space: nowrap;
letter-spacing: 1.5px; text-transform: uppercase; transition: color 0.3s;
font-weight: 300;
}
/* ======== 底部字幕 ======== */
.live-caption {
position: absolute; bottom: 110px; left: 0; right: 0;
text-align: center; padding: 0 40px;
font-size: 15px; line-height: 1.7; color: rgba(255,255,255,0.65);
transition: opacity 0.3s;
max-height: 72px; overflow: hidden;
font-weight: 300;
}
.live-caption .user-text { color: rgba(255,255,255,0.3); font-size: 13px; }
/* ======== 底部按钮 ======== */
.bottom-controls {
position: absolute; bottom: 0; left: 0; right: 0;
display: flex; align-items: center; justify-content: center; gap: 24px;
padding: 32px 20px; padding-bottom: max(36px, env(safe-area-inset-bottom));
background: linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%);
}
.ctrl-btn {
width: 48px; height: 48px; border-radius: 50%; border: none;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); position: relative;
}
.ctrl-btn:active { transform: scale(0.9); }
.ctrl-btn svg { width: 20px; height: 20px; fill: #fff; }
.ctrl-btn.mic {
background: rgba(255,255,255,0.1);
backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.08);
}
.ctrl-btn.mic:hover { background: rgba(255,255,255,0.18); }
.ctrl-btn.mic.muted { background: rgba(239,68,68,0.2); border-color: rgba(239,68,68,0.25); }
.ctrl-btn.mic.muted svg { fill: #fca5a5; }
.ctrl-btn.end {
width: 56px; height: 56px; background: #ef4444;
box-shadow: 0 2px 20px rgba(239,68,68,0.35);
}
.ctrl-btn.end:hover { background: #dc2626; box-shadow: 0 2px 28px rgba(239,68,68,0.5); }
.ctrl-btn.end svg { width: 24px; height: 24px; }
.ctrl-btn.text-input {
background: rgba(255,255,255,0.06);
backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.06);
}
.ctrl-btn.text-input:hover { background: rgba(255,255,255,0.12); }
/* 文字输入浮层 */
.text-overlay {
position: absolute; bottom: 100px; left: 20px; right: 20px;
display: flex; gap: 10px; opacity: 0; pointer-events: none; transition: all 0.3s;
transform: translateY(10px);
}
.text-overlay.show { opacity: 1; pointer-events: all; transform: translateY(0); }
.text-overlay input {
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
border-radius: 24px; padding: 11px 20px; color: #fff; font-size: 14px; outline: none;
backdrop-filter: blur(20px); font-weight: 300;
}
.text-overlay input::placeholder { color: rgba(255,255,255,0.25); }
.text-overlay input:focus { border-color: rgba(16,185,129,0.4); }
.text-overlay button {
width: 44px; height: 44px; border-radius: 50%; border: none;
background: #10b981; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.text-overlay button:hover { background: #059669; }
.text-overlay button svg { width: 20px; height: 20px; fill: #fff; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="app">
<!-- ======== 启动页 ======== -->
<div class="landing" id="landing">
<div class="landing-orb" onclick="handleStart()"></div>
<div class="landing-text">AI 语音助手</div>
<div class="landing-sub">点击光球或按钮开始对话</div>
<button class="landing-start" id="btnStart" onclick="handleStart()">开始对话</button>
</div>
<!-- ======== 通话界面 ======== -->
<div class="call-view" id="callView">
<!-- 历史字幕 -->
<div class="caption-area" id="captionArea"></div>
<!-- 中心光球 -->
<div class="orb-container" id="orbContainer" data-state="idle">
<div class="orb-ring r3"></div>
<div class="orb-ring r2"></div>
<div class="orb-ring r1"></div>
<div class="orb"></div>
<div class="orb-label" id="orbLabel">准备中</div>
</div>
<!-- 实时字幕 -->
<div class="live-caption" id="liveCaption"></div>
<!-- 文字输入浮层 -->
<div class="text-overlay" id="textOverlay">
<input id="textInput" placeholder="输入文字发送给 AI..." onkeydown="if(event.key==='Enter')handleSendText()" />
<button onmousedown="event.preventDefault()" ontouchstart="event.preventDefault()" onclick="handleSendText()">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
<!-- 底部控制 -->
<div class="bottom-controls">
<button class="ctrl-btn text-input" onclick="toggleTextInput()" title="文字输入">
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></svg>
</button>
<button class="ctrl-btn mic" id="btnMic" onclick="handleToggleMic()" title="麦克风">
<svg id="micOn" viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
<svg id="micOff" class="hidden" viewBox="0 0 24 24"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>
</button>
<button class="ctrl-btn end" onclick="handleStop()" title="结束通话">
<svg viewBox="0 0 24 24"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.18-.29-.43-.29-.71 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.1-.7-.28-.79-.73-1.68-1.36-2.66-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg>
</button>
</div>
</div>
</div>
<!-- RTC SDK -->
<script src="https://unpkg.com/@volcengine/rtc@4.66.20/index.min.js"></script>
<!-- AIGC Voice Client -->
<script src="./aigc-voice-client.js"></script>
<script>
const client = new AigcVoiceClient({ serverUrl: `http://${location.hostname}:3001` });
const $ = (id) => document.getElementById(id);
let textInputVisible = false;
// ============ 光球状态 ============
function setOrbState(state, label) {
$('orbContainer').dataset.state = state;
$('orbLabel').textContent = label;
}
// ============ 字幕渲染 ============
function renderCaptions(msgs) {
const area = $('captionArea');
area.innerHTML = '';
msgs.forEach((msg) => {
const isUser = msg.userId === client.userId;
const div = document.createElement('div');
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
let html = escapeHtml(msg.text);
if (!isUser && msg.isInterrupted) {
html += '<span class="interrupted-tag">已打断</span>';
}
div.innerHTML = html;
area.appendChild(div);
});
area.scrollTop = area.scrollHeight;
// 实时字幕:显示最后一条
const last = msgs[msgs.length - 1];
if (last) {
const isUser = last.userId === client.userId;
$('liveCaption').innerHTML = isUser
? `<span class="user-text">${escapeHtml(last.text)}</span>`
: escapeHtml(last.text);
}
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ============ 回调 ============
client.onStateChange = (state) => {
const mic = $('btnMic');
if (state.isMicOn) {
mic.classList.remove('muted');
$('micOn').classList.remove('hidden');
$('micOff').classList.add('hidden');
} else {
mic.classList.add('muted');
$('micOn').classList.add('hidden');
$('micOff').classList.remove('hidden');
}
};
client.onAIThinking = () => setOrbState('thinking', '思考中');
client.onAISpeaking = () => setOrbState('speaking', '');
client.onAIFinished = () => setOrbState('listening', '聆听中');
client.onAIInterrupted = () => {
setOrbState('listening', '已打断');
const msgs = client.msgHistory;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].userId !== client.userId) { msgs[i].isInterrupted = true; break; }
}
renderCaptions(msgs);
};
client.onSubtitle = () => renderCaptions(client.msgHistory);
client.onError = (err) => {
console.error(err);
setOrbState('idle', '出错了');
};
// ============ 操作 ============
async function handleStart() {
const btn = $('btnStart');
btn.disabled = true;
btn.textContent = '连接中...';
try {
await client.init();
$('landing').classList.add('fade-out');
$('callView').classList.add('active');
setOrbState('idle', '连接中');
await client.start();
setOrbState('listening', '聆听中');
} catch (e) {
console.error(e);
$('landing').classList.remove('fade-out');
$('callView').classList.remove('active');
btn.disabled = false;
btn.textContent = '开始对话';
alert('连接失败: ' + e.message);
}
}
async function handleStop() {
await client.stop();
$('callView').classList.remove('active');
$('landing').classList.remove('fade-out');
$('btnStart').disabled = false;
$('btnStart').textContent = '开始对话';
$('captionArea').innerHTML = '';
$('liveCaption').innerHTML = '';
setOrbState('idle', '');
}
function handleToggleMic() { client.toggleMic(); }
function toggleTextInput() {
textInputVisible = !textInputVisible;
$('textOverlay').classList.toggle('show', textInputVisible);
if (textInputVisible) $('textInput').focus();
}
function handleSendText() {
const input = $('textInput');
const text = input.value.trim();
if (!text || !client.audioBotEnabled) return;
input.value = '';
client.sendTextToLLM(text);
client.msgHistory.push({
text, userId: client.userId,
definite: true, paragraph: true, time: new Date().toISOString(),
});
renderCaptions(client.msgHistory);
toggleTextInput();
}
</script>
</body>
</html>