rtc-voice-chat/simple-frontend/example.html
2026-04-02 20:15:15 +08:00

857 lines
37 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>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; }
/* ============ 历史面板 ============ */
.history-panel {
position: absolute; top: 0; right: 0; bottom: 0; width: 340px; z-index: 40;
background: rgba(7,11,20,0.96); border-left: 1px solid rgba(255,255,255,0.07);
backdrop-filter: blur(24px); display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.4,0,0.2,1);
}
.history-panel.open { transform: translateX(0); }
.history-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.history-title { font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.7); }
.history-close {
width: 28px; height: 28px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4);
cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.history-close:hover { background: rgba(255,255,255,0.12); }
.history-list {
flex: 1; overflow-y: auto; padding: 8px 0;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
}
.history-item {
padding: 12px 20px; cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background 0.15s;
}
.history-item:hover { background: rgba(255,255,255,0.04); }
.history-item.active { background: rgba(16,185,129,0.06); }
.history-item-date {
font-size: 11px; color: rgba(255,255,255,0.25); margin-bottom: 4px;
}
.history-item-preview {
font-size: 13px; color: rgba(255,255,255,0.55); font-weight: 300;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item-meta {
font-size: 11px; color: rgba(255,255,255,0.2); margin-top: 4px;
}
/* 对话详情 */
.history-detail {
position: absolute; inset: 0;
background: rgba(7,11,20,0.98);
display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1);
}
.history-detail.open { transform: translateX(0); }
.history-detail-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.history-back {
width: 28px; height: 28px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; flex-shrink: 0;
}
.history-back:hover { background: rgba(255,255,255,0.12); }
.history-detail-title { font-size: 13px; color: rgba(255,255,255,0.5); flex: 1; }
.history-messages {
flex: 1; overflow-y: auto; padding: 16px;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
display: flex; flex-direction: column; gap: 10px;
}
.history-msg {
max-width: 85%; padding: 8px 12px; border-radius: 12px;
font-size: 13px; line-height: 1.6; font-weight: 300;
}
.history-msg.user {
align-self: flex-end;
background: rgba(37,99,235,0.2); color: rgba(255,255,255,0.65);
border-bottom-right-radius: 4px;
}
.history-msg.bot {
align-self: flex-start;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.7);
border-bottom-left-radius: 4px;
}
.history-continue-btn {
margin: 16px; padding: 12px;
background: #10b981; border: none; border-radius: 10px;
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
transition: background 0.2s; flex-shrink: 0;
}
.history-continue-btn:hover { background: #059669; }
.history-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
font-size: 13px; color: rgba(255,255,255,0.2);
}
/* 历史按钮(启动页和通话页) */
.hist-btn {
position: absolute; top: 20px; left: 20px; z-index: 22;
width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4);
cursor: pointer; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(10px); transition: all 0.2s;
}
.hist-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); }
.hist-btn svg { width: 16px; height: 16px; fill: currentColor; }
/* 历史消息在字幕区的样式 */
.caption-msg.historical { opacity: 0.35; font-size: 13px; }
.caption-divider {
text-align: center; font-size: 11px; color: rgba(255,255,255,0.2);
padding: 8px 0; letter-spacing: 1px;
}
/* ============ 登录页 ============ */
.login-view {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 30;
transition: opacity 0.5s ease;
}
.login-view.fade-out { opacity: 0; pointer-events: none; }
.login-box {
width: 320px; padding: 36px 32px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px; backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 16px;
}
.login-title {
font-size: 18px; font-weight: 400; color: rgba(255,255,255,0.85);
text-align: center; margin-bottom: 4px; letter-spacing: 0.5px;
}
.login-sub {
font-size: 12px; color: rgba(255,255,255,0.25);
text-align: center; margin-top: -8px; margin-bottom: 4px;
}
.login-input {
width: 100%; padding: 11px 16px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #fff; font-size: 14px; outline: none;
transition: border-color 0.2s; font-weight: 300;
}
.login-input::placeholder { color: rgba(255,255,255,0.2); }
.login-input:focus { border-color: rgba(16,185,129,0.5); }
.login-btn {
width: 100%; padding: 12px;
background: #10b981; border: none; border-radius: 10px;
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
transition: background 0.2s; margin-top: 4px;
}
.login-btn:hover { background: #059669; }
.login-btn:disabled { opacity: 0.4; cursor: wait; }
.login-error {
font-size: 12px; color: #f87171; text-align: center;
min-height: 16px; margin-top: -4px;
}
.login-user {
position: absolute; top: 20px; right: 20px; z-index: 25;
font-size: 12px; color: rgba(255,255,255,0.3);
display: flex; align-items: center; gap: 8px;
}
.login-user span { color: rgba(255,255,255,0.5); }
.logout-btn {
background: none; border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; padding: 3px 10px; color: rgba(255,255,255,0.3);
font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.logout-btn:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.6); }
</style>
</head>
<body>
<div class="app">
<!-- ======== 登录页 ======== -->
<div class="login-view" id="loginView">
<div class="login-box">
<div class="login-title">AI 语音助手</div>
<div class="login-sub">admin / admin123 &nbsp;·&nbsp; user1 / user123</div>
<input class="login-input" id="loginUsername" placeholder="用户名" autocomplete="username"
onkeydown="if(event.key==='Enter')document.getElementById('loginPassword').focus()" />
<input class="login-input" id="loginPassword" type="password" placeholder="密码" autocomplete="current-password"
onkeydown="if(event.key==='Enter')handleLogin()" />
<div class="login-error" id="loginError"></div>
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
</div>
</div>
<!-- 已登录用户信息(右上角) -->
<div class="login-user hidden" id="userInfo">
<span id="userNickname"></span>
<button class="logout-btn" onclick="handleLogout()">退出</button>
</div>
<!-- 历史按钮(登录后显示,浮在左上角) -->
<button class="hist-btn hidden" id="histBtn" onclick="openHistory()" title="历史记录">
<svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>
</button>
<!-- ======== 历史面板 ======== -->
<div class="history-panel" id="historyPanel">
<div class="history-header">
<span class="history-title">对话历史</span>
<button class="history-close" onclick="closeHistory()">×</button>
</div>
<div class="history-list" id="historyList"></div>
<!-- 详情子面板(叠加在历史面板上) -->
<div class="history-detail" id="historyDetail">
<div class="history-detail-header">
<button class="history-back" onclick="closeDetail()"></button>
<span class="history-detail-title" id="detailTitle"></span>
</div>
<div class="history-messages" id="historyMessages"></div>
<button class="history-continue-btn" id="continueBtn" onclick="handleContinue()">
接着这次记录继续对话
</button>
</div>
</div>
<!-- ======== 启动页 ======== -->
<div class="landing hidden" 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 JAVA_MOCK_URL = `http://${location.hostname}:8080`;
const $ = (id) => document.getElementById(id);
let client = null;
let currentUser = null;
// ============ 登录 ============
async function handleLogin() {
const username = $('loginUsername').value.trim();
const password = $('loginPassword').value.trim();
if (!username || !password) {
$('loginError').textContent = '请输入用户名和密码';
return;
}
$('loginBtn').disabled = true;
$('loginBtn').textContent = '登录中...';
$('loginError').textContent = '';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message || '登录失败');
currentUser = { name: json.data.name, deptName: json.data.deptName, roleList: json.data.roleList };
const token = json.data.token;
// 初始化 AigcVoiceClient指向 java-mock带上 JWT
client = new AigcVoiceClient({ serverUrl: JAVA_MOCK_URL, authToken: token });
setupClientCallbacks();
// 注册 beforeunload 兜底保存
window.addEventListener('beforeunload', () => {
if (client && client.msgHistory.length > 0) {
navigator.sendBeacon(
`${JAVA_MOCK_URL}/api/ai/conversations`,
new Blob([JSON.stringify({
sceneId: client.sceneId,
roomId: client.roomId,
messages: client.msgHistory
.filter((m) => !m._historical)
.map((m) => ({ role: m.role, content: m.content, time: m.time })),
})], { type: 'application/json' })
);
}
});
$('loginView').classList.add('fade-out');
$('userNickname').textContent = currentUser.name;
$('userInfo').classList.remove('hidden');
$('histBtn').classList.remove('hidden');
$('landing').classList.remove('hidden');
} catch (e) {
$('loginError').textContent = e.message;
$('loginBtn').disabled = false;
$('loginBtn').textContent = '登录';
}
}
function handleLogout() {
client = null;
currentUser = null;
$('landing').classList.add('hidden');
$('callView').classList.remove('active');
$('userInfo').classList.add('hidden');
$('histBtn').classList.add('hidden');
closeHistory();
$('loginView').classList.remove('fade-out');
$('loginUsername').value = '';
$('loginPassword').value = '';
$('loginBtn').disabled = false;
$('loginBtn').textContent = '登录';
}
function setupClientCallbacks() {
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].role === 'assistant') { msgs[i].isInterrupted = true; break; }
}
renderCaptions(msgs);
};
client.onSubtitle = () => renderCaptions(client.msgHistory);
client.onError = (err) => { console.error(err); setOrbState('idle', '出错了'); };
}
let textInputVisible = false;
// ============ 光球状态 ============
function setOrbState(state, label) {
$('orbContainer').dataset.state = state;
$('orbLabel').textContent = label;
}
// ============ 字幕渲染 ============
function renderCaptions(msgs) {
const area = $('captionArea');
area.innerHTML = '';
let hadHistory = false;
msgs.forEach((msg) => {
if (msg._historical) {
hadHistory = true;
const div = document.createElement('div');
div.className = `caption-msg historical ${msg.role === 'user' ? 'user' : 'bot'}`;
div.textContent = msg.content;
area.appendChild(div);
return;
}
if (hadHistory) {
const divider = document.createElement('div');
divider.className = 'caption-divider';
divider.textContent = '— 本次对话 —';
area.appendChild(divider);
hadHistory = false;
}
const isUser = msg.role === 'user';
const div = document.createElement('div');
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
let html = escapeHtml(msg.content);
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 && !last._historical) {
$('liveCaption').innerHTML = last.role === 'user'
? `<span class="user-text">${escapeHtml(last.content)}</span>`
: escapeHtml(last.content);
}
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ============ 操作 ============
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', '');
// 重置启动页提示文字
const sub = document.querySelector('.landing-sub');
sub.textContent = '点击光球或按钮开始对话';
sub.style.color = '';
}
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({
role: 'user', content: text,
definite: true, paragraph: true, time: new Date().toISOString(),
});
renderCaptions(client.msgHistory);
toggleTextInput();
}
// ============ 历史面板 ============
let selectedConversation = null;
async function openHistory() {
$('historyPanel').classList.add('open');
await loadHistoryList();
}
function closeHistory() {
$('historyPanel').classList.remove('open');
closeDetail();
}
async function loadHistoryList() {
const list = $('historyList');
list.innerHTML = '<div class="history-empty">加载中...</div>';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations?size=50`, {
headers: { 'Authorization': `Bearer ${client.authToken}` },
});
const json = await res.json();
const items = json.data?.list || [];
if (items.length === 0) {
list.innerHTML = '<div class="history-empty">暂无历史记录</div>';
return;
}
list.innerHTML = '';
items.forEach((item) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.id = item.id;
const date = new Date(item.startedAt).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
div.innerHTML = `
<div class="history-item-date">${date}</div>
<div class="history-item-preview">${escapeHtml(item.firstMessage || '(无文字记录)')}</div>
<div class="history-item-meta">${item.messageCount} 条消息 · ${item.sceneId}</div>
`;
div.addEventListener('click', () => openDetail(item.id));
list.appendChild(div);
});
} catch (e) {
list.innerHTML = '<div class="history-empty">加载失败</div>';
}
}
async function openDetail(id) {
$('historyDetail').classList.add('open');
$('historyMessages').innerHTML = '<div style="color:rgba(255,255,255,0.2);padding:20px;font-size:13px">加载中...</div>';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations/${id}`, {
headers: { 'Authorization': `Bearer ${client.authToken}` },
});
const json = await res.json();
const conv = json.data;
selectedConversation = conv;
const date = new Date(conv.startedAt).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
$('detailTitle').textContent = date + ' · ' + conv.sceneId;
const msgsEl = $('historyMessages');
msgsEl.innerHTML = '';
conv.messages.forEach((m) => {
const div = document.createElement('div');
div.className = `history-msg ${m.role === 'user' ? 'user' : 'bot'}`;
div.textContent = m.content;
msgsEl.appendChild(div);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
} catch (e) {
$('historyMessages').innerHTML = '<div style="color:#f87171;padding:20px;font-size:13px">加载失败</div>';
}
}
function closeDetail() {
$('historyDetail').classList.remove('open');
selectedConversation = null;
}
function handleContinue() {
if (!selectedConversation) return;
const conv = selectedConversation; // closeHistory 会把 selectedConversation 置 null先保存
client.loadHistory(conv.messages, conv.id);
closeHistory();
// 跳转到启动页,准备开始新对话
$('landing').classList.remove('fade-out', 'hidden');
$('callView').classList.remove('active');
// 在启动页显示提示
const sub = document.querySelector('.landing-sub');
sub.textContent = `将接续 ${new Date(conv.startedAt).toLocaleDateString('zh-CN')} 的对话`;
sub.style.color = 'rgba(16,185,129,0.6)';
}
</script>
</body>
</html>