ChatBotPage.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  1. import { useEffect, useRef, useState } from 'react';
  2. import { message as antMessage } from 'antd';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { useAppStore } from '@/store';
  5. import { AudioRecorder } from '@/utils/audioRecorder';
  6. import { AudioPlayer } from '@/utils/audioPlayer';
  7. import { WebSocketManager } from '@/utils/websocket';
  8. import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
  9. import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '@/utils/storage';
  10. import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
  11. import { performanceLogger } from '@/utils/logger';
  12. import { getUserInfo } from '@/utils/api';
  13. import type { Message } from '@/types';
  14. import '@/styles/chatbot-fix.css';
  15. import avatarC from '@/assets/Avatar-C.svg';
  16. import treesImage from '@/assets/Group-3465183.svg';
  17. import animaZoneAvatar from '@/assets/Avatar-anima-zone.png';
  18. import audioButton from '@/assets/audio-button.png';
  19. import { AudioWave } from '@/components/AudioWave';
  20. import microphoneIcon from '@/assets/microphone-icon.svg';
  21. import progressRing from '@/assets/progress-ring.svg';
  22. import progressFull from '@/assets/progress-full.svg';
  23. import tapToChat from '@/assets/TapToChat.svg';
  24. import cancelIcon from '@/assets/cancel-icon.svg';
  25. import sendIcon from '@/assets/send-icon.svg';
  26. import tooltipsCancel from '@/assets/tooltips-cancel.svg';
  27. import tooltipsSend from '@/assets/tooltips-send.svg';
  28. import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
  29. const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
  30. export const ChatBotPage = () => {
  31. const {
  32. currentState,
  33. currentRequestId,
  34. currentEmotion,
  35. conversationProgress,
  36. progressLoopCount,
  37. setState,
  38. setRequestId,
  39. setEmotion,
  40. setRecognizedText,
  41. setError,
  42. setConnected,
  43. updateProgressByDuration,
  44. setProgress,
  45. reset,
  46. } = useAppStore();
  47. const recorderRef = useRef<AudioRecorder | null>(null);
  48. const playerRef = useRef<AudioPlayer | null>(null);
  49. const wsRef = useRef<WebSocketManager | null>(null);
  50. const chunkIndexRef = useRef(0);
  51. const mobileAudioPreparedRef = useRef(false);
  52. const sendTimeRef = useRef<number>(0);
  53. // 初始化时检查 localStorage,确保第一次打开时立即显示提示
  54. const [showTapToChat, setShowTapToChat] = useState(() => {
  55. const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
  56. if (!hasShown) {
  57. // 第一次打开,立即保存标记并显示
  58. setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
  59. return true;
  60. }
  61. return false;
  62. });
  63. const sendingTimeoutRef = useRef<number | null>(null);
  64. const [showCancelTooltip, setShowCancelTooltip] = useState(false);
  65. const [showSendTooltip, setShowSendTooltip] = useState(false);
  66. const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
  67. const progressBarTooltipTimerRef = useRef<number | null>(null);
  68. // 控制声纹容器高度(延迟降低,等待衰减动画完成)
  69. const [shouldExpandWaveform, setShouldExpandWaveform] = useState(false);
  70. const waveformCollapseTimerRef = useRef<number | null>(null);
  71. // 用于动画的显示进度(平滑过渡)
  72. const [displayProgress, setDisplayProgress] = useState(0);
  73. const animationFrameRef = useRef<number>();
  74. const totalProgressRef = useRef(0);
  75. const shouldSkipProgressAnimationRef = useRef(true);
  76. // V2: 完全计算式布局系统
  77. const [layout, setLayout] = useState<CalculatedLayout>(() => {
  78. const initialLayout = calculateLayout();
  79. return initialLayout;
  80. });
  81. const timingRef = useRef({
  82. recordStart: 0,
  83. recordEnd: 0,
  84. firstAudioReceived: 0,
  85. emotionReceived: 0,
  86. });
  87. // 进度动画效果(支持多圈连续动画)
  88. useEffect(() => {
  89. const targetTotal = progressLoopCount * 100 + conversationProgress;
  90. const startTotal = totalProgressRef.current;
  91. const diff = targetTotal - startTotal;
  92. if (shouldSkipProgressAnimationRef.current) {
  93. shouldSkipProgressAnimationRef.current = false;
  94. totalProgressRef.current = targetTotal;
  95. const normalized = ((targetTotal % 100) + 100) % 100;
  96. setDisplayProgress(normalized);
  97. return;
  98. }
  99. // 若变化极小,直接同步
  100. if (Math.abs(diff) < 0.1) {
  101. totalProgressRef.current = targetTotal;
  102. const normalized = ((targetTotal % 100) + 100) % 100;
  103. setDisplayProgress(normalized);
  104. return;
  105. }
  106. // 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
  107. const durationPerPercent = 8;
  108. const duration = Math.max(400, Math.min(2400, Math.abs(diff) * durationPerPercent));
  109. const startTime = performance.now();
  110. if (animationFrameRef.current) {
  111. cancelAnimationFrame(animationFrameRef.current);
  112. }
  113. const animate = (currentTime: number) => {
  114. const elapsed = currentTime - startTime;
  115. const progress = Math.min(elapsed / duration, 1);
  116. // easeOutCubic 缓动
  117. const easeProgress = 1 - Math.pow(1 - progress, 3);
  118. const currentTotal = startTotal + diff * easeProgress;
  119. totalProgressRef.current = currentTotal;
  120. const normalized = ((currentTotal % 100) + 100) % 100;
  121. setDisplayProgress(normalized);
  122. if (progress < 1) {
  123. animationFrameRef.current = requestAnimationFrame(animate);
  124. } else {
  125. totalProgressRef.current = targetTotal;
  126. const finalNormalized = ((targetTotal % 100) + 100) % 100;
  127. setDisplayProgress(finalNormalized);
  128. }
  129. };
  130. animationFrameRef.current = requestAnimationFrame(animate);
  131. return () => {
  132. if (animationFrameRef.current) {
  133. cancelAnimationFrame(animationFrameRef.current);
  134. }
  135. };
  136. }, [conversationProgress, progressLoopCount]);
  137. // 控制声纹容器高度变化
  138. useEffect(() => {
  139. // 清除之前的定时器
  140. if (waveformCollapseTimerRef.current) {
  141. clearTimeout(waveformCollapseTimerRef.current);
  142. waveformCollapseTimerRef.current = null;
  143. }
  144. if (currentState === 'recording' || currentState === 'playing') {
  145. // 录音或播放时,立即展开
  146. setShouldExpandWaveform(true);
  147. } else {
  148. // 其他状态(sending/idle 等),延迟 3 秒后再收起(等待衰减动画完成)
  149. waveformCollapseTimerRef.current = window.setTimeout(() => {
  150. setShouldExpandWaveform(false);
  151. }, 3000); // 3秒足够让所有竖条衰减为静态高度
  152. }
  153. return () => {
  154. if (waveformCollapseTimerRef.current) {
  155. clearTimeout(waveformCollapseTimerRef.current);
  156. }
  157. };
  158. }, [currentState]);
  159. // 监听录音状态,首次显示取消提示(3秒后自动隐藏)
  160. useEffect(() => {
  161. if (currentState === 'recording') {
  162. const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
  163. if (!hasShownCancel) {
  164. setShowCancelTooltip(true);
  165. setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
  166. // 3秒后自动隐藏
  167. const timer = setTimeout(() => {
  168. setShowCancelTooltip(false);
  169. }, 3000);
  170. return () => clearTimeout(timer);
  171. } else {
  172. // 已显示过,确保隐藏
  173. setShowCancelTooltip(false);
  174. }
  175. } else {
  176. // 非录音状态,隐藏提示
  177. setShowCancelTooltip(false);
  178. }
  179. }, [currentState]);
  180. // 监听录音状态,首次显示发送提示(3秒后自动隐藏)
  181. useEffect(() => {
  182. if (currentState === 'recording') {
  183. const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
  184. if (!hasShownSend) {
  185. setShowSendTooltip(true);
  186. setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
  187. // 3秒后自动隐藏
  188. const timer = setTimeout(() => {
  189. setShowSendTooltip(false);
  190. }, 3000);
  191. return () => clearTimeout(timer);
  192. } else {
  193. // 已显示过,确保隐藏
  194. setShowSendTooltip(false);
  195. }
  196. } else {
  197. // 非录音状态,隐藏提示
  198. setShowSendTooltip(false);
  199. }
  200. }, [currentState]);
  201. // 监听进度条变化,首次有进度时显示进度条提示(2秒后自动隐藏)
  202. // 🔧 临时修改:为了UI调试,进度更新时始终显示提示
  203. useEffect(() => {
  204. // const hasShownProgressBar = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
  205. // // 如果已经显示过,不再处理
  206. // if (hasShownProgressBar) {
  207. // return;
  208. // }
  209. // 当进度条第一次大于0时显示提示(只显示一次)
  210. const hasShownProgressBarTooltip = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
  211. if (displayProgress > 0 && !showProgressBarTooltip && !hasShownProgressBarTooltip) {
  212. setShowProgressBarTooltip(true);
  213. setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
  214. // 2秒后自动隐藏
  215. progressBarTooltipTimerRef.current = window.setTimeout(() => {
  216. setShowProgressBarTooltip(false);
  217. progressBarTooltipTimerRef.current = null;
  218. }, 2000);
  219. }
  220. return () => {
  221. if (progressBarTooltipTimerRef.current) {
  222. clearTimeout(progressBarTooltipTimerRef.current);
  223. progressBarTooltipTimerRef.current = null;
  224. }
  225. };
  226. }, [displayProgress, showProgressBarTooltip]);
  227. // 监听屏幕尺寸变化
  228. useEffect(() => {
  229. // 初始化时更新标记
  230. updateTooSmallFlag();
  231. const cleanup = onScreenResize(() => {
  232. const newLayout = calculateLayout();
  233. setLayout(newLayout); // V2: 更新完全计算式布局
  234. // 更新 data-too-small 标记
  235. updateTooSmallFlag();
  236. });
  237. return cleanup;
  238. }, []);
  239. // 初始化
  240. useEffect(() => {
  241. // 记录用户当前所在页面
  242. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
  243. // 记录路由访问
  244. markRouteVisited('/chatbot');
  245. const init = async () => {
  246. try {
  247. // 获取用户信息并初始化进度条
  248. const user_id = getUserId();
  249. if (user_id) {
  250. try {
  251. const userInfo = await getUserInfo(user_id);
  252. if (userInfo.success && userInfo.data.total_speech_duration_ms) {
  253. // 根据总说话时长计算初始进度(30秒=100%)
  254. const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
  255. const totalDurationMs = userInfo.data.total_speech_duration_ms || 0;
  256. const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
  257. const initialLoopCount = Math.floor(totalProgressPercent / 100);
  258. const initialProgress = totalProgressPercent % 100;
  259. shouldSkipProgressAnimationRef.current = true;
  260. setProgress(initialProgress, initialLoopCount);
  261. }
  262. } catch (error) {
  263. // 获取用户信息失败不影响主流程,静默处理
  264. console.warn('获取用户信息失败:', error);
  265. }
  266. }
  267. // 初始化录音器
  268. const recorder = new AudioRecorder();
  269. await recorder.initialize();
  270. recorderRef.current = recorder;
  271. // 初始化播放器
  272. const player = new AudioPlayer();
  273. await player.initialize();
  274. player.onEnd(() => {
  275. const requestId = useAppStore.getState().currentRequestId;
  276. if (requestId) {
  277. performanceLogger.recordPlaybackEnd(requestId);
  278. }
  279. // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
  280. setState('idle');
  281. reset();
  282. });
  283. playerRef.current = player;
  284. // 初始化WebSocket(添加user_id查询参数)
  285. const ws = new WebSocketManager();
  286. const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
  287. ws.connect(wsUrl, (connected) => {
  288. setConnected(connected);
  289. if (connected) {
  290. // 记录WebSocket连接建立时间
  291. performanceLogger.recordWebSocketConnected();
  292. } else {
  293. setState('error');
  294. setError('WebSocket连接断开');
  295. }
  296. });
  297. ws.onMessage('app', handleMessage);
  298. wsRef.current = ws;
  299. } catch (error) {
  300. antMessage.error('初始化失败,请检查麦克风权限');
  301. setError('初始化失败');
  302. }
  303. };
  304. init();
  305. return () => {
  306. recorderRef.current?.release();
  307. playerRef.current?.release();
  308. wsRef.current?.disconnect();
  309. if (sendingTimeoutRef.current) {
  310. clearTimeout(sendingTimeoutRef.current);
  311. }
  312. if (animationFrameRef.current) {
  313. cancelAnimationFrame(animationFrameRef.current);
  314. }
  315. if (progressBarTooltipTimerRef.current) {
  316. clearTimeout(progressBarTooltipTimerRef.current);
  317. }
  318. };
  319. }, []);
  320. // 处理WebSocket消息
  321. const handleMessage = (message: Message) => {
  322. const requestId = message.requestId || currentRequestId;
  323. switch (message.type) {
  324. case 'text_result':
  325. if (message.isFinal) {
  326. performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
  327. } else if (!performanceLogger.hasFirstText(requestId)) {
  328. performanceLogger.recordFirstText(requestId, message.text, message.timestamp);
  329. }
  330. setRecognizedText(message.text);
  331. break;
  332. case 'emotion':
  333. performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
  334. setEmotion(message.emotion);
  335. break;
  336. case 'audio_response':
  337. // 清除发送超时
  338. if (sendingTimeoutRef.current) {
  339. clearTimeout(sendingTimeoutRef.current);
  340. sendingTimeoutRef.current = null;
  341. }
  342. if (currentState !== 'playing') {
  343. performanceLogger.recordFirstAudio(requestId, message.timestamp);
  344. performanceLogger.recordPlaybackStart(requestId);
  345. setState('playing');
  346. }
  347. if (message.data) {
  348. const audioData = atob(message.data);
  349. const audioBytes = new Uint8Array(audioData.length);
  350. for (let i = 0; i < audioData.length; i++) {
  351. audioBytes[i] = audioData.charCodeAt(i);
  352. }
  353. playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000);
  354. }
  355. if (message.isLast) {
  356. performanceLogger.recordLastAudio(requestId);
  357. playerRef.current?.markStreamEnd();
  358. // 进度已在发送时增加,这里不再重复增加
  359. }
  360. break;
  361. case 'error':
  362. console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
  363. antMessage.error(message.message);
  364. setError(message.message);
  365. performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
  366. break;
  367. case 'pong':
  368. break;
  369. default:
  370. console.warn(`[未知消息] [${requestId?.substring(0, 8) || ''}]`, message.type);
  371. break;
  372. }
  373. };
  374. // 开始录音
  375. const handleStartRecord = async () => {
  376. // 如果正在录音,忽略
  377. if (currentState === 'recording') {
  378. return;
  379. }
  380. // 如果正在播放,先停止播放
  381. if (currentState === 'playing') {
  382. playerRef.current?.stop();
  383. }
  384. // 其他状态(idle/error/sending)都允许开始录音
  385. // 首次点击录音按钮,隐藏 TapToChat 提示
  386. if (showTapToChat) {
  387. setShowTapToChat(false);
  388. }
  389. // 点击录音按钮时,隐藏进度条提示
  390. if (showProgressBarTooltip) {
  391. setShowProgressBarTooltip(false);
  392. if (progressBarTooltipTimerRef.current) {
  393. clearTimeout(progressBarTooltipTimerRef.current);
  394. progressBarTooltipTimerRef.current = null;
  395. }
  396. }
  397. if (isMobileDevice() && !mobileAudioPreparedRef.current) {
  398. try {
  399. await prepareMobileAudio();
  400. requestVolumeControl();
  401. mobileAudioPreparedRef.current = true;
  402. antMessage.info('请确保设备音量已调高');
  403. } catch (error) {
  404. // 准备移动端音频失败,静默处理
  405. }
  406. }
  407. // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
  408. playerRef.current?.stop();
  409. const requestId = uuidv4();
  410. performanceLogger.startRequest(requestId);
  411. setRequestId(requestId);
  412. setState('recording');
  413. chunkIndexRef.current = 0;
  414. setRecognizedText('');
  415. timingRef.current = {
  416. recordStart: Date.now(),
  417. recordEnd: 0,
  418. firstAudioReceived: 0,
  419. emotionReceived: 0,
  420. };
  421. recorderRef.current?.start(
  422. (pcmData) => {
  423. if (chunkIndexRef.current === 0) {
  424. performanceLogger.recordFirstChunkSent(requestId);
  425. }
  426. const uint8Array = new Uint8Array(pcmData.buffer);
  427. const base64 = btoa(String.fromCharCode(...Array.from(uint8Array)));
  428. wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
  429. chunkIndexRef.current++;
  430. }
  431. );
  432. };
  433. // 发送录音
  434. const handleSend = () => {
  435. if (currentState !== 'recording' || !currentRequestId) {
  436. return;
  437. }
  438. timingRef.current.recordEnd = Date.now();
  439. sendTimeRef.current = Date.now();
  440. performanceLogger.recordEnd(currentRequestId, chunkIndexRef.current);
  441. // 计算说话时长(毫秒)
  442. const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
  443. // 更新进度条(基于说话时长,30秒=100%)
  444. if (speechDuration > 0) {
  445. updateProgressByDuration(speechDuration);
  446. }
  447. recorderRef.current?.stop();
  448. // 发送音频结束消息,包含说话时长信息
  449. wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current, speechDuration);
  450. setState('sending');
  451. // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置
  452. if (sendingTimeoutRef.current) {
  453. clearTimeout(sendingTimeoutRef.current);
  454. }
  455. sendingTimeoutRef.current = window.setTimeout(() => {
  456. const state = useAppStore.getState().currentState;
  457. if (state === 'sending' || state === 'playing') {
  458. console.warn(`[超时] [${currentRequestId.substring(0, 8)}] 响应超时,自动重置`);
  459. antMessage.warning('响应超时,请重试');
  460. setState('idle');
  461. reset();
  462. performanceLogger.clearRequest(currentRequestId, '响应超时');
  463. }
  464. }, 10000);
  465. };
  466. // 取消录音
  467. const handleCancel = () => {
  468. if (currentState !== 'recording' || !currentRequestId) return;
  469. console.log(`[取消] [${currentRequestId.substring(0, 8)}] 取消录音`);
  470. recorderRef.current?.stop();
  471. wsRef.current?.sendCancel(currentRequestId);
  472. performanceLogger.clearRequest(currentRequestId);
  473. reset();
  474. };
  475. return (
  476. <div
  477. className={`chatbot-container relative w-full ${
  478. layout.needsScroll ? 'h-auto overflow-y-auto overflow-x-hidden' : 'h-screen overflow-hidden'
  479. }`}
  480. style={{
  481. minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
  482. // ⚠️ 移除 safe-area-inset class,改用手动控制顶部安全区域
  483. // 底部不加 padding,让声波纹可以直接贴底定位
  484. paddingTop: 'env(safe-area-inset-top)',
  485. paddingLeft: 'env(safe-area-inset-left)',
  486. paddingRight: 'env(safe-area-inset-right)',
  487. }}
  488. >
  489. {/* 背景图片 */}
  490. <div
  491. className="chatbot-bg absolute inset-0 pointer-events-none"
  492. style={{
  493. backgroundImage: 'url(/tolan-bg.svg)',
  494. backgroundSize: 'cover',
  495. backgroundPosition: 'center',
  496. backgroundRepeat: 'no-repeat',
  497. zIndex: 0,
  498. }}
  499. />
  500. {/* 内容容器 - 自适应所有屏幕尺寸 */}
  501. <div
  502. className="relative w-full"
  503. style={{
  504. boxSizing: 'border-box',
  505. minHeight: layout.needsScroll ? `${layout.contentHeight}px` : '100vh',
  506. height: layout.needsScroll ? 'auto' : '100%',
  507. }}
  508. >
  509. {/* 树木装饰 - 设计稿:top 175px, 宽度 395.03px, 高度 220.66px, 左右居中 */}
  510. <img
  511. src={treesImage}
  512. alt="Trees"
  513. className="chatbot-trees absolute pointer-events-none"
  514. style={{
  515. width: `${layout.treesWidth}px`,
  516. height: 'auto',
  517. top: `${layout.treesTop}px`,
  518. left: '50%',
  519. transform: 'translateX(-50%)',
  520. opacity: 1,
  521. zIndex: 1,
  522. }}
  523. />
  524. {/* Avatar-C 头像 - 设计稿:top 63px, right 63px, 尺寸 40×40 */}
  525. <img
  526. src={avatarC}
  527. alt="Avatar C"
  528. className="chatbot-avatar-c absolute pointer-events-none rounded-full"
  529. style={{
  530. width: `${layout.avatarCSize}px`,
  531. height: `${layout.avatarCSize}px`,
  532. top: `${layout.avatarCTop}px`,
  533. right: `${layout.avatarCRight}px`,
  534. opacity: 0.2,
  535. zIndex: 5,
  536. }}
  537. />
  538. {/* AnimaZone 头像 - 设计稿:top 248px (树木175px + 73px), 尺寸 343×340, 左右居中 */}
  539. <img
  540. src={animaZoneAvatar}
  541. alt="AnimaZone Avatar"
  542. className="chatbot-anima-zone-avatar absolute"
  543. style={{
  544. width: `${layout.mainAvatarWidth}px`,
  545. height: `${layout.mainAvatarHeight}px`,
  546. top: `${layout.animaZoneTop}px`,
  547. left: '50%',
  548. transform: 'translateX(-50%)',
  549. opacity: 1,
  550. zIndex: 2,
  551. }}
  552. />
  553. {/* 情感展示 - 设计稿:距离界面顶部 50px,左右居中 */}
  554. <div
  555. className="chatbot-emotion absolute"
  556. style={{
  557. top: `${layout.emotionTop}px`,
  558. left: '50%',
  559. transform: 'translateX(-50%)',
  560. textAlign: 'center',
  561. zIndex: 3,
  562. }}
  563. >
  564. <div
  565. className="text-fluid-sm"
  566. style={{
  567. fontWeight: 500,
  568. color: 'rgba(255, 255, 255, 0.9)',
  569. textShadow: '0px 2px 4px rgba(0, 0, 0, 0.3)',
  570. padding: '6px 16px',
  571. borderRadius: '12px',
  572. background: 'rgba(255, 255, 255, 0.1)',
  573. backdropFilter: 'blur(10px)',
  574. border: '1px solid rgba(255, 255, 255, 0.2)',
  575. }}
  576. >
  577. {currentEmotion}
  578. </div>
  579. </div>
  580. {/* 音频波形 - 宽度 100% 自适应 */}
  581. <div
  582. className="chatbot-audio-wave overflow-hidden"
  583. style={{
  584. position: 'fixed', // 改为 fixed,直接相对于视口定位
  585. width: '100%',
  586. height: shouldExpandWaveform
  587. ? `${layout.waveformHeight}px`
  588. : '20px',
  589. bottom: `${layout.waveformBottom}px`,
  590. left: 0,
  591. transition: 'height 0.3s ease-in-out',
  592. zIndex: 10,
  593. }}
  594. data-expanded={shouldExpandWaveform ? 'true' : 'false'}
  595. data-waveform-bottom={layout.waveformBottom}
  596. >
  597. <div
  598. style={{
  599. position: 'absolute',
  600. bottom: 0,
  601. left: 0,
  602. width: '100%',
  603. height: `${layout.waveformHeight}px`,
  604. }}
  605. >
  606. <AudioWave
  607. isAnimating={currentState === 'recording' || currentState === 'playing'}
  608. width={typeof window !== 'undefined' ? window.innerWidth : 375}
  609. height={layout.waveformHeight}
  610. />
  611. </div>
  612. </div>
  613. {/* 进度条+录音按钮容器 - 设计稿:bottom 84px, 尺寸 114×114, 左右居中 */}
  614. {currentState !== 'recording' && (
  615. <div
  616. className="chatbot-progress-container absolute"
  617. style={{
  618. width: `${layout.progressRingSize}px`,
  619. height: `${layout.progressRingSize}px`,
  620. top: `${layout.progressRingTop}px`,
  621. left: '50%',
  622. transform: 'translateX(-50%)',
  623. zIndex: 20,
  624. }}
  625. >
  626. {/* 进度环 - 背景层 */}
  627. <div
  628. className="chatbot-progress-ring absolute"
  629. style={{
  630. width: '100%',
  631. height: '100%',
  632. top: 0,
  633. left: '-0.5px',
  634. }}
  635. >
  636. {/* 空状态背景 */}
  637. <img
  638. src={progressRing}
  639. alt="Progress Ring"
  640. className="pointer-events-none"
  641. style={{
  642. position: 'absolute',
  643. top: 0,
  644. left: 0,
  645. width: '100%',
  646. height: '100%',
  647. opacity: 1,
  648. }}
  649. />
  650. {/* 进度填充层 - 使用conic-gradient mask */}
  651. <div
  652. className="pointer-events-none"
  653. style={{
  654. position: 'absolute',
  655. top: '5px',
  656. left: 0,
  657. width: '100%',
  658. height: '100%',
  659. maskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
  660. WebkitMaskImage: `conic-gradient(from 215deg at 50% 50%, black 0deg, black ${displayProgress * 2.88}deg, transparent ${displayProgress * 2.88}deg, transparent 360deg)`,
  661. }}
  662. >
  663. <img
  664. src={progressFull}
  665. alt="Progress Full"
  666. style={{
  667. width: '100%',
  668. height: '100%',
  669. display: 'block',
  670. }}
  671. />
  672. </div>
  673. </div>
  674. {/* 录音按钮 - 设计稿:尺寸 96×96,在进度环内居中,左右居中 */}
  675. <div
  676. className="chatbot-record-button absolute cursor-pointer touch-manipulation"
  677. onClick={handleStartRecord}
  678. style={{
  679. width: `${layout.recordButtonSize}px`,
  680. height: `${layout.recordButtonSize}px`,
  681. minWidth: '44px',
  682. minHeight: '44px',
  683. top: `${layout.recordButtonOffset}px`,
  684. left: '50%',
  685. transform: 'translateX(-50%)',
  686. borderRadius: '100px',
  687. opacity: 1,
  688. display: 'flex',
  689. alignItems: 'center',
  690. justifyContent: 'center',
  691. userSelect: 'none',
  692. WebkitUserSelect: 'none',
  693. caretColor: 'transparent',
  694. outline: 'none',
  695. }}
  696. contentEditable={false}
  697. tabIndex={-1}
  698. >
  699. {/* 录音按钮背景 - PNG 图片 */}
  700. <img
  701. src={audioButton}
  702. alt="Audio Button Background"
  703. className="pointer-events-none"
  704. style={{
  705. width: '100%',
  706. height: '100%',
  707. position: 'absolute',
  708. top: 0,
  709. left: 0,
  710. }}
  711. />
  712. {/* 麦克风图标 - 居中 - 响应式尺寸 */}
  713. <img
  714. src={microphoneIcon}
  715. alt="Microphone"
  716. className="pointer-events-none"
  717. style={{
  718. width: `${layout.recordButtonSize * 0.33}px`,
  719. height: `${layout.recordButtonSize * 0.33}px`,
  720. opacity: 1,
  721. position: 'relative',
  722. zIndex: 1,
  723. }}
  724. />
  725. </div>
  726. </div>
  727. )}
  728. {/* 进度条提示 - 首次有进度时显示 */}
  729. {showProgressBarTooltip && (
  730. <img
  731. src={tooltipsProgressBar}
  732. alt="Progress Bar Tooltip"
  733. className="chatbot-tooltips absolute pointer-events-none"
  734. style={{
  735. width: `${layout.tooltips.progressBar.width}px`,
  736. height: `${layout.tooltips.progressBar.height}px`,
  737. top: `${layout.tooltips.progressBar.top}px`,
  738. left: `${layout.tooltips.progressBar.left}px`,
  739. opacity: 1,
  740. }}
  741. />
  742. )}
  743. {/* Tap to Chat 提示 - 只显示一次 - 等比例缩放 */}
  744. {showTapToChat && (
  745. <img
  746. src={tapToChat}
  747. alt="Tap to Chat"
  748. className="chatbot-tooltips absolute pointer-events-none"
  749. style={{
  750. width: `${layout.tooltips.tapToChat.width}px`,
  751. height: 'auto',
  752. top: `${layout.tooltips.tapToChat.top}px`,
  753. right: `${layout.tooltips.tapToChat.right}px`,
  754. borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
  755. opacity: 1,
  756. }}
  757. />
  758. )}
  759. {/* 取消语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
  760. {currentState === 'recording' && showCancelTooltip && (
  761. <img
  762. src={tooltipsCancel}
  763. alt="Cancel Tooltip"
  764. className="chatbot-tooltips absolute pointer-events-none"
  765. style={{
  766. width: `${layout.tooltips.cancel.width}px`,
  767. height: 'auto',
  768. top: `${layout.tooltips.cancel.top}px`,
  769. left: `${layout.tooltips.cancel.left}px`,
  770. borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
  771. opacity: 1,
  772. }}
  773. />
  774. )}
  775. {/* 取消语音发送按钮 - 录音状态显示,与录音按钮同高度,左侧对齐 */}
  776. {currentState === 'recording' && (
  777. <div
  778. className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
  779. onClick={handleCancel}
  780. style={{
  781. width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
  782. height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
  783. minWidth: '44px',
  784. minHeight: '44px',
  785. top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
  786. left: '50%',
  787. transform: `translateX(calc(-100% - ${layout.verticalGap}px))`,
  788. borderRadius: '100px',
  789. opacity: 1,
  790. background: 'rgba(255, 255, 255, 0.1)',
  791. backdropFilter: 'blur(15px)',
  792. zIndex: 20,
  793. userSelect: 'none',
  794. WebkitUserSelect: 'none',
  795. caretColor: 'transparent',
  796. outline: 'none',
  797. }}
  798. contentEditable={false}
  799. tabIndex={-1}
  800. >
  801. {/* 按钮背景装饰图片 */}
  802. <img
  803. src={audioButton}
  804. alt="Button Background"
  805. className="pointer-events-none"
  806. style={{
  807. width: 'calc(100% + 1px)',
  808. height: 'calc(100% + 1px)',
  809. position: 'absolute',
  810. top: '0',
  811. left: '0',
  812. }}
  813. />
  814. {/* Cancel 图标 - 居中 - 响应式尺寸 */}
  815. <img
  816. src={cancelIcon}
  817. alt="Cancel"
  818. className="pointer-events-none"
  819. style={{
  820. width: `${layout.recordButtonSize * 0.25}px`,
  821. height: `${layout.recordButtonSize * 0.25}px`,
  822. position: 'relative',
  823. zIndex: 1,
  824. }}
  825. />
  826. </div>
  827. )}
  828. {/* 语音发送提示 - 只在首次录音时显示 - 等比例缩放 */}
  829. {currentState === 'recording' && showSendTooltip && (
  830. <img
  831. src={tooltipsSend}
  832. alt="Send Tooltip"
  833. className="chatbot-tooltips absolute pointer-events-none"
  834. style={{
  835. width: `${layout.tooltips.send.width}px`,
  836. height: 'auto',
  837. top: `${layout.tooltips.send.top}px`,
  838. left: `${layout.tooltips.send.left}px`,
  839. borderRadius: `${Math.round(8 * layout.avatarScale)}px`,
  840. opacity: 1,
  841. }}
  842. />
  843. )}
  844. {/* 语音发送按钮 - 录音状态显示,与录音按钮同高度,右侧对齐 */}
  845. {currentState === 'recording' && (
  846. <div
  847. className="chatbot-action-buttons absolute flex items-center justify-center cursor-pointer touch-manipulation"
  848. onClick={handleSend}
  849. style={{
  850. width: `${Math.round(layout.recordButtonSize * 0.58)}px`,
  851. height: `${Math.round(layout.recordButtonSize * 0.58)}px`,
  852. minWidth: '44px',
  853. minHeight: '44px',
  854. top: `${layout.progressRingTop + layout.recordButtonOffset}px`,
  855. left: '50%',
  856. transform: `translateX(${layout.verticalGap}px)`,
  857. borderRadius: '100px',
  858. opacity: 1,
  859. background: 'rgba(255, 255, 255, 0.1)',
  860. backdropFilter: 'blur(15px)',
  861. zIndex: 20,
  862. userSelect: 'none',
  863. WebkitUserSelect: 'none',
  864. caretColor: 'transparent',
  865. outline: 'none',
  866. }}
  867. contentEditable={false}
  868. tabIndex={-1}
  869. >
  870. {/* 按钮背景装饰图片 */}
  871. <img
  872. src={audioButton}
  873. alt="Button Background"
  874. className="pointer-events-none"
  875. style={{
  876. width: 'calc(100% + 1px)',
  877. height: 'calc(100% + 1px)',
  878. position: 'absolute',
  879. top: '0',
  880. left: '0',
  881. }}
  882. />
  883. {/* Send 图标 - 居中 - 响应式尺寸 */}
  884. <img
  885. src={sendIcon}
  886. alt="Send"
  887. className="pointer-events-none"
  888. style={{
  889. width: `${layout.recordButtonSize * 0.25}px`,
  890. height: `${layout.recordButtonSize * 0.25}px`,
  891. position: 'relative',
  892. zIndex: 1,
  893. }}
  894. />
  895. </div>
  896. )}
  897. </div>
  898. </div>
  899. );
  900. };