ChatBotPage.tsx 148 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722
  1. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
  2. import { flushSync } from 'react-dom';
  3. import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
  4. import { useTranslation } from 'react-i18next';
  5. import { message as antMessage } from 'antd';
  6. import { v4 as uuidv4 } from 'uuid';
  7. import { useAppStore } from '@/store';
  8. import { AudioRecorder, type FrequencyFrame } from '@/utils/audioRecorder';
  9. import { AudioPlayer } from '@/utils/audioPlayer';
  10. import { WebSocketManager } from '@/utils/websocket';
  11. import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
  12. import {
  13. setStorage,
  14. getStorage,
  15. getUserLevel,
  16. getHasSharedAvatar,
  17. STORAGE_KEYS,
  18. markRouteVisited,
  19. getUserId,
  20. getVisitedRoutes,
  21. setUserLevel,
  22. getCurrentBackgroundImageUrl,
  23. getCurrentBackgroundImageId,
  24. getCurrentBackgroundImageOpacity,
  25. syncBackgroundImageToStorage,
  26. setUserName,
  27. getUserToken,
  28. setUserToken,
  29. clearUserSessionAndRedirectToBegin,
  30. ROUTE_ORDER,
  31. } from '@/utils/storage';
  32. import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
  33. import { performanceLogger } from '@/utils/logger';
  34. import {
  35. getUserInfo,
  36. updateUserUIState,
  37. getUserUIState,
  38. getAvatarGifs,
  39. getIntimacy,
  40. lightUpIntimacy,
  41. getIntimacyStatus,
  42. type AvatarGifSet,
  43. ensureSession,
  44. getApiBaseUrl,
  45. getApiHeaders,
  46. } from '@/utils/api';
  47. import { px2vmin } from '@/utils/viewport';
  48. import type { Message, EnergyStoneSpawnMessage } from '@/types';
  49. import '@/styles/chatbot-fix.css';
  50. import { getOssUrl } from '@/utils/oss';
  51. const tapToChat = getOssUrl('TapToChat.svg');
  52. const cancelIcon = getOssUrl('cancel-icon.png');
  53. const sendIcon = getOssUrl('send-icon.png');
  54. const tooltipsCancel = getOssUrl('tooltips-cancel.svg');
  55. const tooltipsSend = getOssUrl('tooltips-send.svg');
  56. /** 是否展示聊天页的 4 个引导 tips(Tap to Chat / 取消 / 发送 / 进度条) */
  57. const ENABLE_CHATBOT_TIPS = false;
  58. const luyinDefault = getOssUrl('luyin-default.png');
  59. const luyinDisable = getOssUrl('luyin-disable.png');
  60. const settingIcon = getOssUrl('setting-icon.png');
  61. const funcLockIcon = getOssUrl('func-Lock.png');
  62. const profileMenuIcon = getOssUrl('profile-icon.png');
  63. const shopMenuIcon = getOssUrl('shop-icon.png');
  64. const worldMenuIcon = getOssUrl('world-icon.png');
  65. const diaryMenuIcon = getOssUrl('diary-icon.png');
  66. const ccCloseIcon = getOssUrl('cc-close-icon.png');
  67. const ccOpenIcon = getOssUrl('cc-open-icon.png');
  68. const missionEnergyStone = getOssUrl('Mission-Energy-Stone.png');
  69. const conversationEnergyStone = getOssUrl('Conversation-Energy-Stone.png');
  70. const missionEnergyStoneHint = getOssUrl('Mission-Energy-Stone-hint.png');
  71. const conversationEnergyStoneHint = getOssUrl('Conversation-Energy-Stone-hint.png');
  72. const heartFilled = getOssUrl('Heart.png');
  73. const heartBroken = getOssUrl('Heart_broken.png');
  74. const conversationEnergyStoneCountBg = getOssUrl('conversion-energy-stone-count.png');
  75. const YpImage = getOssUrl('yp.png');
  76. const audioWaveGif = getOssUrl('audio-wave.gif');
  77. const bgChattingUrl = getOssUrl('bg-chatting.png');
  78. import { useEnergyStones, type EnergyStoneEventPayload } from '@/hooks/useEnergyStones';
  79. import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
  80. import { IntimacyModal } from '@/components/IntimacyModal';
  81. const MAX_RECORDING_DURATION_MS = 60_000;
  82. const AVATAR_C_UNLOCK_THRESHOLD_MS = 60_000;
  83. const COUNTDOWN_THRESHOLD_MS = 10_000;
  84. const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
  85. // 控制台调试开关:生产环境关闭,避免多余日志
  86. const shouldLogDebug = false;
  87. const debugLog = (...args: unknown[]) => {
  88. if (!shouldLogDebug) return;
  89. // 使用 console.debug 统一输出调试日志
  90. // eslint-disable-next-line no-console
  91. console.debug(...args);
  92. };
  93. // GIF URL 兜底常量(基础形象)
  94. const FALLBACK_DEFAULT_AVATAR_GIF = getOssUrl('01-Default.gif');
  95. const FALLBACK_POSITIVE_EMOTION_GIF = getOssUrl('04-Positive-emotion.gif');
  96. const FALLBACK_NEGATIVE_EMOTION_GIF = getOssUrl('05-Negative-emotion.gif');
  97. const FALLBACK_TALKING_GIF = getOssUrl('06-talking.gif');
  98. // GIF URL 兜底常量(注册赠送形象:带帽子)
  99. const FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF = getOssUrl('01-Default-Registered.gif');
  100. const FALLBACK_REGISTERED_POSITIVE_EMOTION_GIF = getOssUrl('04-Positive-emotion-Registered.gif');
  101. const FALLBACK_REGISTERED_NEGATIVE_EMOTION_GIF = getOssUrl('05-Negative-emotion-Registered.gif');
  102. const FALLBACK_REGISTERED_TALKING_GIF = getOssUrl('06-talking-Registered.gif');
  103. // 形象 ID 常量(与后端/迁移脚本一致)
  104. const AVATAR_ID_DEFAULT = 'avatar_default';
  105. const AVATAR_ID_REGISTERED = 'avatar_registered_cap';
  106. type AvatarState = 'default' | 'positive' | 'negative' | 'talking';
  107. // 情绪分类函数
  108. const getEmotionCategory = (emotion: string): 'positive' | 'negative' | 'neutral' => {
  109. // 正向情绪
  110. const positiveEmotions = ['happy', 'excited', 'encouraging', 'satisfied', 'friendly'];
  111. // 反向情绪
  112. const negativeEmotions = ['sad', 'angry', 'worried', 'confused', 'sorry'];
  113. if (positiveEmotions.includes(emotion)) {
  114. return 'positive';
  115. }
  116. if (negativeEmotions.includes(emotion)) {
  117. return 'negative';
  118. }
  119. // neutral, surprised, thoughtful 等归为中性
  120. return 'neutral';
  121. };
  122. // 获取情绪 GIF 播放时长(毫秒)
  123. // 统一:正向 2050ms,负向 4290ms(与形象无关)
  124. const getEmotionGifDuration = (_avatarId: string | null, emotionCategory: 'positive' | 'negative'): number => {
  125. if (emotionCategory === 'positive') {
  126. return 2050;
  127. }
  128. if (emotionCategory === 'negative') {
  129. return 4290;
  130. }
  131. return 0;
  132. };
  133. export const ChatBotPage = () => {
  134. const { t: tCommon } = useTranslation('common');
  135. const { t: tProfile } = useTranslation('profile');
  136. const navigate = useNavigate();
  137. const [searchParams] = useSearchParams();
  138. const location = useLocation();
  139. const {
  140. currentState,
  141. currentRequestId,
  142. conversationProgress,
  143. progressLoopCount,
  144. setState,
  145. setRequestId,
  146. setEmotion,
  147. setRecognizedText,
  148. setError,
  149. setConnected,
  150. setProgress,
  151. reset,
  152. isConnected,
  153. recognizedText,
  154. } = useAppStore();
  155. const recorderRef = useRef<AudioRecorder | null>(null);
  156. const playerRef = useRef<AudioPlayer | null>(null);
  157. const wsRef = useRef<WebSocketManager | null>(null);
  158. const chunkIndexRef = useRef(0);
  159. const connectionLostRef = useRef(false);
  160. // 首句 greeting 请求 ID(来自后端的 greeting_xxx)
  161. const greetingRequestIdRef = useRef<string | null>(null);
  162. // 标记首句是否处于“可播放/可显示”状态;一旦用户开始录音则置为 true,后续 greeting 全部丢弃
  163. const greetingSuppressedRef = useRef(false);
  164. // 标记当前是否正在播放首句音频,便于在开始录音时精确打断
  165. const isGreetingActiveRef = useRef(false);
  166. // 标记首句 greeting 是否已经发起,避免重复触发
  167. const hasGreetingStartedRef = useRef(false);
  168. // 标记在 WebSocket 尚未完成初始化时是否需要在连接成功后自动触发首句 greeting
  169. const pendingGreetingRef = useRef(false);
  170. const activeRequestIdRef = useRef<string | null>(null);
  171. const cancelledRequestIdsRef = useRef<Set<string>>(new Set());
  172. const mobileAudioPreparedRef = useRef(false);
  173. // 如果亲密度弹窗正在展示,则不播放首句 greeting(直接丢弃相关消息)
  174. const delayGreetingRef = useRef(false);
  175. // 字幕相关状态(默认从 localStorage 读取,若无则为 false)
  176. const [subtitleOn, setSubtitleOn] = useState(() => {
  177. return getStorage(STORAGE_KEYS.SUBTITLE_ON) === 'true';
  178. });
  179. const subtitleContainerRef = useRef<HTMLDivElement | null>(null);
  180. const [subtitleVisible, setSubtitleVisible] = useState(false);
  181. const subtitleOnRef = useRef(subtitleOn);
  182. // 字幕自动滚动相关:仅当字幕消失时停止,无其他取消逻辑
  183. const subtitleAutoScrollRafRef = useRef<number | null>(null);
  184. const subtitleLastFrameTimeRef = useRef<number | null>(null);
  185. const sendTimeRef = useRef<number>(0);
  186. const isMountedRef = useRef(true);
  187. const [, setUserLevelValue] = useState(() => getUserLevel());
  188. const [, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
  189. // 背景图片 URL
  190. const [backgroundImageUrl, setBackgroundImageUrl] = useState<string | null>(() => {
  191. // 初始化时从 localStorage 获取背景图片 URL
  192. const url = getCurrentBackgroundImageUrl();
  193. return url || null;
  194. });
  195. // 背景图片 ID(只保留 setter,因为变量本身未使用)
  196. const [, setBackgroundImageId] = useState<string | null>(() => {
  197. return getCurrentBackgroundImageId();
  198. });
  199. // 初始化时检查 localStorage,确保第一次打开时立即显示提示(由 ENABLE_CHATBOT_TIPS 控制是否展示)
  200. const [showTapToChat, setShowTapToChat] = useState(() => {
  201. if (!ENABLE_CHATBOT_TIPS) return false;
  202. const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
  203. if (hasShown) return false;
  204. return true;
  205. });
  206. const sendingTimeoutRef = useRef<number | null>(null);
  207. const updateRequestId = useCallback(
  208. (id: string | null) => {
  209. activeRequestIdRef.current = id;
  210. setRequestId(id);
  211. },
  212. [setRequestId]
  213. );
  214. useEffect(() => {
  215. activeRequestIdRef.current = currentRequestId;
  216. }, [currentRequestId]);
  217. const [showCancelTooltip, setShowCancelTooltip] = useState(false);
  218. const [showSendTooltip, setShowSendTooltip] = useState(false);
  219. const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
  220. const [showMissionStoneModal, setShowMissionStoneModal] = useState(false);
  221. const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
  222. const [showIntimacyModal, setShowIntimacyModal] = useState(false);
  223. const [showFuncMenuPopup, setShowFuncMenuPopup] = useState(false);
  224. const [intimacyHeartCount, setIntimacyHeartCount] = useState(3);
  225. const [intimacyDaysSinceLastVisit, setIntimacyDaysSinceLastVisit] = useState(0);
  226. const [intimacyLoading, setIntimacyLoading] = useState(false);
  227. const [canLightUpToday, setCanLightUpToday] = useState(true);
  228. // 同步亲密度弹窗状态到 ref,供 WebSocket 消息处理时使用
  229. useEffect(() => {
  230. delayGreetingRef.current = showIntimacyModal;
  231. }, [showIntimacyModal]);
  232. // 明确触发首句 greeting:由前端控制时机(如亲密度弹窗关闭后)
  233. const startGreeting = () => {
  234. // 已经发起过首句,或用户已开始录音(选择以用户为准)时,不再触发 greeting
  235. if (hasGreetingStartedRef.current || greetingSuppressedRef.current) {
  236. debugLog('[首句] 已经发起过或用户已开始录音,跳过 start_greeting');
  237. return;
  238. }
  239. const ws = wsRef.current;
  240. if (!ws) {
  241. // WebSocket 尚未初始化完成:标记为待发送,等连接成功后由回调统一触发
  242. debugLog('[首句] WebSocket 未初始化,标记 pendingGreetingRef = true,等待连接成功后再发送 start_greeting');
  243. pendingGreetingRef.current = true;
  244. return;
  245. }
  246. // 一旦真正发送首句 greeting,则清除待发送标记
  247. pendingGreetingRef.current = false;
  248. hasGreetingStartedRef.current = true;
  249. const requestId = `greeting_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
  250. debugLog('[首句] 发送 start_greeting 消息', { requestId });
  251. ws.send({
  252. type: 'start_greeting',
  253. requestId,
  254. });
  255. };
  256. const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
  257. const [hasClickedMissionStone, setHasClickedMissionStone] = useState(() => {
  258. return getStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE) === 'true';
  259. });
  260. const [hasClickedConversationStone, setHasClickedConversationStone] = useState(() => {
  261. return getStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE) === 'true';
  262. });
  263. const [hasShownProgressBarTooltip, setHasShownProgressBarTooltip] = useState(() => {
  264. const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
  265. return stored === 'true';
  266. });
  267. const progressBarTooltipTimerRef = useRef<number | null>(null);
  268. const progressBarTooltipRef = useRef<HTMLImageElement | null>(null);
  269. const pendingStoneIdRef = useRef<string | null>(null);
  270. const [isAvatarCUnlocked, setIsAvatarCUnlocked] = useState(() => {
  271. const stored = getStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED);
  272. return stored === 'true';
  273. });
  274. const avatarCUnlockedRef = useRef(isAvatarCUnlocked);
  275. useEffect(() => {
  276. avatarCUnlockedRef.current = isAvatarCUnlocked;
  277. }, [isAvatarCUnlocked]);
  278. // 累计总说话时长(仅作为从后端同步后的本地缓存,进度和解锁逻辑均以后端为准)
  279. const totalSpeechDurationRef = useRef<number>(
  280. isAvatarCUnlocked ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
  281. );
  282. // 用于动画的显示进度(平滑过渡)
  283. const [displayProgress, setDisplayProgress] = useState(0);
  284. const animationFrameRef = useRef<number>();
  285. const totalProgressRef = useRef(0);
  286. // 默认置为 true,避免组件初次挂载或路由返回时尚未完成数据同步就触发历史进度动画。
  287. const shouldSkipProgressAnimationRef = useRef(true);
  288. // 保存上一次的进度状态,用于计算动画起始值
  289. const prevProgressStateRef = useRef<{ progress: number; loopCount: number }>({
  290. progress: 0,
  291. loopCount: 0,
  292. });
  293. const recordingTimerRef = useRef<number | null>(null);
  294. const recordingDeadlineRef = useRef<number>(0);
  295. const handleSendRef = useRef<() => void>();
  296. const [remainingTimeMs, setRemainingTimeMs] = useState<number | null>(null);
  297. const pushFrequencyFrame = useCallback((_frame: FrequencyFrame) => {
  298. // 底部声波纹已移除,不再推送数据
  299. }, []);
  300. const resetFrequencyVisualization = useCallback(() => {}, []);
  301. // 清理字幕文本中的情感前缀(例如 [EMOTION: happy] 你好 → 你好)
  302. const cleanSubtitleText = useCallback((text: string): string => {
  303. if (!text) return '';
  304. // 1) 去掉开头的 [EMOTION:xxx] 或 [emotion:xxx](支持中英文冒号)
  305. let cleaned = text.replace(/^\s*\[emotion[::][^\]]*]\s*/i, '');
  306. // 2) 兼容早期格式:去掉开头的【...】或[...] 这一类简单情感标签
  307. cleaned = cleaned.replace(/^\s*[\[【][^[\]【】]{1,16}[\]】]\s*/, '');
  308. return cleaned.trim();
  309. }, []);
  310. // 同步字幕开关状态到 ref,便于在 AudioPlayer 回调中读取最新值
  311. useEffect(() => {
  312. subtitleOnRef.current = subtitleOn;
  313. }, [subtitleOn]);
  314. // 只要语音在播报就持续向下滚动,不主动停;仅当字幕消失(不可见或容器卸载)时停止
  315. const subtitleScrollActiveRef = useRef({ subtitleVisible, currentState });
  316. const subtitleScrollStepCountRef = useRef(0);
  317. const subtitleScrollTargetRef = useRef(0);
  318. const subtitleLastTextLengthRef = useRef(0);
  319. // recognizedText 更新后恢复滚动位置,避免重渲染把 scrollTop 重置为 0
  320. useLayoutEffect(() => {
  321. if (!recognizedText || currentState !== 'playing') return;
  322. const el = subtitleContainerRef.current;
  323. if (!el) return;
  324. const max = el.scrollHeight - el.clientHeight;
  325. if (max <= 0) return;
  326. const target = Math.min(subtitleScrollTargetRef.current, max);
  327. if (target > 0) {
  328. el.scrollTop = target;
  329. }
  330. }, [recognizedText, currentState]);
  331. useEffect(() => {
  332. subtitleScrollActiveRef.current = { subtitleVisible, currentState };
  333. if (!subtitleVisible || currentState !== 'playing') return;
  334. if (!recognizedText) return;
  335. const container = subtitleContainerRef.current;
  336. if (!container) return;
  337. const textLen = recognizedText.length;
  338. subtitleLastTextLengthRef.current = textLen;
  339. // 已有滚动在跑时不再取消、不重启,避免 contentIncreased 时反复重启导致 scrollTop 停在约 1
  340. if (subtitleAutoScrollRafRef.current !== null) {
  341. console.log('[SubtitleScroll] effect skip', { reason: 'RAF already running', textLen });
  342. return () => {};
  343. }
  344. subtitleLastFrameTimeRef.current = null;
  345. subtitleScrollStepCountRef.current = 0;
  346. subtitleScrollTargetRef.current = container.scrollTop;
  347. console.log('[SubtitleScroll] effect start', {
  348. textLen,
  349. scrollTop: container.scrollTop,
  350. maxScroll: container.scrollHeight - container.clientHeight,
  351. });
  352. const step = (timestamp: number) => {
  353. const active = subtitleScrollActiveRef.current;
  354. if (!active.subtitleVisible || active.currentState !== 'playing') {
  355. console.log('[SubtitleScroll] step stop', { reason: 'active ref', active });
  356. subtitleAutoScrollRafRef.current = null;
  357. subtitleLastFrameTimeRef.current = null;
  358. return;
  359. }
  360. const el = subtitleContainerRef.current;
  361. if (!el) {
  362. console.log('[SubtitleScroll] step stop', { reason: 'el null' });
  363. subtitleAutoScrollRafRef.current = null;
  364. subtitleLastFrameTimeRef.current = null;
  365. return;
  366. }
  367. const maxScrollTop = el.scrollHeight - el.clientHeight;
  368. if (maxScrollTop <= 0) {
  369. subtitleAutoScrollRafRef.current = requestAnimationFrame(step);
  370. return;
  371. }
  372. const maxScrollTopCurrent = el.scrollHeight - el.clientHeight;
  373. const targetRef = subtitleScrollTargetRef.current;
  374. // 每帧用 targetRef 恢复并推进,不依赖 el.scrollTop(重渲染会把它打回导致卡住)
  375. const currentScroll = Math.min(Math.max(targetRef, 0), maxScrollTopCurrent);
  376. el.scrollTop = currentScroll;
  377. const nearBottom = currentScroll >= maxScrollTopCurrent - 3;
  378. if (nearBottom) {
  379. el.scrollTop = maxScrollTopCurrent;
  380. subtitleScrollTargetRef.current = maxScrollTopCurrent;
  381. } else {
  382. const last = subtitleLastFrameTimeRef.current;
  383. subtitleLastFrameTimeRef.current = timestamp;
  384. if (last != null) {
  385. const deltaMs = timestamp - last;
  386. const linesPerSecond = 0.2;
  387. const speedPerSecond = (el.clientHeight / 3) * linesPerSecond;
  388. const delta = (speedPerSecond * deltaMs) / 1000;
  389. // 基于 targetRef 推进,不读 el.scrollTop,避免被重渲染重置后推进缓慢
  390. const nextTop = Math.min(currentScroll + delta, maxScrollTopCurrent);
  391. el.scrollTop = nextTop;
  392. subtitleScrollTargetRef.current = nextTop;
  393. const n = (subtitleScrollStepCountRef.current += 1);
  394. if (n === 1 || n % 90 === 0) {
  395. console.log('[SubtitleScroll] step scroll', {
  396. step: n,
  397. scrollTop: Math.round(nextTop * 10) / 10,
  398. max: maxScrollTopCurrent,
  399. delta: Math.round(delta * 10) / 10,
  400. pct: Math.round((nextTop / maxScrollTopCurrent) * 100),
  401. });
  402. }
  403. }
  404. }
  405. subtitleAutoScrollRafRef.current = requestAnimationFrame(step);
  406. };
  407. subtitleAutoScrollRafRef.current = requestAnimationFrame((ts) => {
  408. subtitleLastFrameTimeRef.current = ts;
  409. subtitleAutoScrollRafRef.current = requestAnimationFrame(step);
  410. });
  411. return () => {};
  412. }, [recognizedText, subtitleVisible, currentState]);
  413. // V2: 完全计算式布局系统
  414. const [, setLayout] = useState<CalculatedLayout>(() => {
  415. const initialLayout = calculateLayout();
  416. return initialLayout;
  417. });
  418. // 对话能量石数量(用于顶部状态展示)
  419. const fetchConversationStoneCount = useCallback(async (userId: string) => {
  420. try {
  421. const apiBaseUrl = getApiBaseUrl();
  422. const request = async () =>
  423. fetch(`${apiBaseUrl}/api/users/${userId}/energy-stones/count`, {
  424. method: 'GET',
  425. headers: getApiHeaders(),
  426. });
  427. let response = await request();
  428. if (response.status === 401) {
  429. console.warn('[ChatBotPage] 请求能量石数量接口返回 401,认为会话失效,准备清空本地并跳转 /begin', {
  430. userId,
  431. });
  432. clearUserSessionAndRedirectToBegin();
  433. return;
  434. }
  435. if (response.ok) {
  436. const data = await response.json();
  437. if (data.success && data.data) {
  438. setConversationStoneCount(data.data.conversation_count || 0);
  439. }
  440. }
  441. } catch (error) {
  442. console.error('[ChatBotPage] 获取对话能量石数量失败:', error);
  443. }
  444. }, []);
  445. // GIF 切换定时器(情绪 GIF 播放一次后切回 talking/default)
  446. const emotionGifTimerRef = useRef<number | null>(null);
  447. // 待处理情绪缓存:收到 emotion 消息时先缓存,等音频真正开始播放时再驱动情绪动画
  448. const pendingEmotionRef = useRef<string | null>(null);
  449. // 已收到 isLast 的 requestId:同轮滞后 chunk 不再重置流、不再次触发 onStart
  450. const responseCompleteRequestIdRef = useRef<string | null>(null);
  451. // 已为该 requestId 触发过「流开始」(传过至少一个 chunk 且未 skipStreamReset);同一 request 后续所有 chunk 一律 skipStreamReset,保证一次对话多段音频只触发一次 onStart、只闪一次开心
  452. const streamStartedForRequestIdRef = useRef<string | null>(null);
  453. // 本段播放是否已显示过情绪 GIF(开心/难过);兜底:若 onStart 被意外触发两次,第二次只切 talking
  454. const emotionGifShownForStreamRef = useRef<boolean>(false);
  455. // 本轮对话是否已播放过情感 GIF(仅点击录音时重置):强制一轮只播一次
  456. const emotionGifPlayedThisRoundRef = useRef<boolean>(false);
  457. // 当前正在累积字幕的 requestId;同一次对话多段音频的文字做拼接,新请求时重置
  458. const subtitleAccumulatorRequestIdRef = useRef<string | null>(null);
  459. const timingRef = useRef({
  460. recordStart: 0,
  461. recordEnd: 0,
  462. firstAudioReceived: 0,
  463. emotionReceived: 0,
  464. });
  465. // 使用 Web Animations API 驱动飞行动画,避免部分端 CSS 动画被覆盖
  466. const runStoneAnimation = useCallback(
  467. (container: HTMLElement | null, dx: number, dy: number) => {
  468. if (!container) return;
  469. const img = container.querySelector('.energy-stone') as HTMLElement | null;
  470. const keyframes: Keyframe[] = [
  471. {
  472. transform: 'translate(-50%, -50%) translate3d(0, 0, 0)',
  473. opacity: 1,
  474. offset: 0,
  475. },
  476. {
  477. transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0)`,
  478. opacity: 1,
  479. offset: 0.85,
  480. },
  481. {
  482. transform: `translate(-50%, -50%) translate3d(${dx}px, ${dy}px, 0)`,
  483. opacity: 0,
  484. offset: 1,
  485. },
  486. ];
  487. const timing: KeyframeAnimationOptions = {
  488. duration: 1400,
  489. easing: 'ease-in-out',
  490. fill: 'forwards',
  491. };
  492. try {
  493. container.animate(keyframes, timing);
  494. if (img) {
  495. img.animate(
  496. [
  497. { transform: 'translate(-50%, -50%)', opacity: 1, offset: 0 },
  498. { transform: 'translate(-50%, -50%)', opacity: 1, offset: 0.85 },
  499. { transform: 'translate(-50%, -50%)', opacity: 0, offset: 1 },
  500. ],
  501. timing
  502. );
  503. }
  504. } catch (error) {
  505. // 如果 WAAPI 不可用,忽略,保留 CSS 动画兜底
  506. console.warn('[EnergyStone] WAAPI animate failed, fallback to CSS animation', error);
  507. }
  508. },
  509. []
  510. );
  511. // 当前形象ID(支持后端返回/本地缓存)
  512. const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(() => {
  513. return getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
  514. });
  515. const [avatarGifSets, setAvatarGifSets] = useState<Record<string, AvatarGifSet>>({
  516. [AVATAR_ID_DEFAULT]: {
  517. default: FALLBACK_DEFAULT_AVATAR_GIF,
  518. positive: FALLBACK_POSITIVE_EMOTION_GIF,
  519. negative: FALLBACK_NEGATIVE_EMOTION_GIF,
  520. talking: FALLBACK_TALKING_GIF,
  521. },
  522. [AVATAR_ID_REGISTERED]: {
  523. default: FALLBACK_REGISTERED_DEFAULT_AVATAR_GIF,
  524. positive: FALLBACK_REGISTERED_POSITIVE_EMOTION_GIF,
  525. negative: FALLBACK_REGISTERED_NEGATIVE_EMOTION_GIF,
  526. talking: FALLBACK_REGISTERED_TALKING_GIF,
  527. },
  528. });
  529. const resolveAvatarGifSet = useCallback(
  530. (avatarId: string | null | undefined): AvatarGifSet => {
  531. const fallback = avatarGifSets[AVATAR_ID_DEFAULT];
  532. if (!avatarId) {
  533. debugLog(`[resolveAvatarGifSet] avatarId 为 null,返回默认形象`);
  534. return fallback;
  535. }
  536. const set = avatarGifSets[avatarId];
  537. const result = {
  538. default: set?.default || fallback.default,
  539. positive: set?.positive || fallback.positive,
  540. negative: set?.negative || fallback.negative,
  541. talking: set?.talking || fallback.talking,
  542. };
  543. debugLog(`[resolveAvatarGifSet] avatarId=${avatarId}, set存在=${!!set}, result=`, result);
  544. return result;
  545. },
  546. [avatarGifSets],
  547. );
  548. const avatarGifSet = useMemo(() => {
  549. const gifSet = resolveAvatarGifSet(currentAvatarId);
  550. debugLog(`[avatarGifSet] currentAvatarId=${currentAvatarId}, gifSet=`, gifSet);
  551. return gifSet;
  552. }, [currentAvatarId, resolveAvatarGifSet]);
  553. const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(() => {
  554. const storedId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
  555. return resolveAvatarGifSet(storedId).default;
  556. });
  557. const setGifByState = useCallback(
  558. (state: AvatarState) => {
  559. // 实时获取最新的 avatarGifSet,避免闭包问题
  560. const latestAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
  561. // 使用最新的 avatarGifSets 状态来计算 GIF
  562. const fallback = avatarGifSets[AVATAR_ID_DEFAULT];
  563. let latestGifSet: AvatarGifSet;
  564. if (!latestAvatarId) {
  565. latestGifSet = fallback;
  566. } else {
  567. const set = avatarGifSets[latestAvatarId];
  568. latestGifSet = {
  569. default: set?.default || fallback.default,
  570. positive: set?.positive || fallback.positive,
  571. negative: set?.negative || fallback.negative,
  572. talking: set?.talking || fallback.talking,
  573. };
  574. }
  575. const nextGif = latestGifSet[state] || latestGifSet.default;
  576. debugLog(`[setGifByState] state=${state}, currentAvatarId=${currentAvatarId}, latestAvatarId=${latestAvatarId}, nextGif=${nextGif}`);
  577. setCurrentAvatarGif(nextGif);
  578. },
  579. [avatarGifSet, currentAvatarId, avatarGifSets],
  580. );
  581. const showDefaultGif = useCallback(() => setGifByState('default'), [setGifByState]);
  582. const showPositiveGif = useCallback(() => setGifByState('positive'), [setGifByState]);
  583. const showNegativeGif = useCallback(() => setGifByState('negative'), [setGifByState]);
  584. const showTalkingGif = useCallback(() => setGifByState('talking'), [setGifByState]);
  585. // 用 ref 保存最新回调,避免 player 的 onEnd/onBeforeEnd/onStart 闭包陈旧导致形象不同步
  586. const showDefaultGifRef = useRef(showDefaultGif);
  587. const showTalkingGifRef = useRef(showTalkingGif);
  588. const showPositiveGifRef = useRef(showPositiveGif);
  589. const showNegativeGifRef = useRef(showNegativeGif);
  590. showDefaultGifRef.current = showDefaultGif;
  591. showTalkingGifRef.current = showTalkingGif;
  592. showPositiveGifRef.current = showPositiveGif;
  593. showNegativeGifRef.current = showNegativeGif;
  594. // 动画/对话层切到下一层(进入 idle)后,立即将对话层形象设为 default,保证下次对话从 default 开始
  595. const prevAvatarStateRef = useRef(currentState);
  596. useEffect(() => {
  597. if (prevAvatarStateRef.current !== 'idle' && currentState === 'idle') {
  598. showDefaultGif();
  599. }
  600. prevAvatarStateRef.current = currentState;
  601. }, [currentState, showDefaultGif]);
  602. // 预加载动画形象的 GIF
  603. const preloadAvatarGifs = useCallback(
  604. (avatarId: string | null) => {
  605. const gifSet = resolveAvatarGifSet(avatarId);
  606. const gifUrls = [gifSet.default, gifSet.positive, gifSet.negative, gifSet.talking];
  607. // 使用 Image 对象预加载所有 GIF
  608. gifUrls.forEach((url) => {
  609. if (url) {
  610. const img = new Image();
  611. img.src = url;
  612. // 静默处理加载错误,避免影响主流程
  613. img.onerror = () => {
  614. console.warn(`[预加载] GIF 加载失败: ${url}`);
  615. };
  616. }
  617. });
  618. debugLog(`[预加载] 已开始预加载形象 ${avatarId || 'default'} 的所有 GIF`);
  619. },
  620. [resolveAvatarGifSet]
  621. );
  622. // 拉取头像 GIF 配置
  623. useEffect(() => {
  624. const fetchAvatarGifs = async () => {
  625. try {
  626. const res = await getAvatarGifs();
  627. if (!res.success || !res.data) {
  628. return;
  629. }
  630. setAvatarGifSets((prev) => {
  631. const next = { ...prev };
  632. const applyPartial = (key: string, partial?: Partial<AvatarGifSet>) => {
  633. if (!partial) return;
  634. const base = next[key] || prev[AVATAR_ID_DEFAULT];
  635. next[key] = {
  636. default: partial.default || base.default,
  637. positive: partial.positive || base.positive,
  638. negative: partial.negative || base.negative,
  639. talking: partial.talking || base.talking,
  640. };
  641. };
  642. applyPartial(AVATAR_ID_DEFAULT, res.data.avatar_default);
  643. applyPartial(AVATAR_ID_REGISTERED, res.data.avatar_registered_cap);
  644. return next;
  645. });
  646. } catch (error) {
  647. // 静默降级使用兜底
  648. console.warn('获取头像 GIF 配置失败,使用兜底资源', error);
  649. }
  650. };
  651. fetchAvatarGifs();
  652. }, []);
  653. // 当 avatarGifSets 更新或 currentAvatarId 变化时,预加载当前形象的 GIF
  654. useEffect(() => {
  655. if (Object.keys(avatarGifSets).length > 0) {
  656. // 如果 currentAvatarId 为 null,预加载默认形象
  657. const avatarIdToPreload = currentAvatarId || AVATAR_ID_DEFAULT;
  658. preloadAvatarGifs(avatarIdToPreload);
  659. }
  660. }, [avatarGifSets, currentAvatarId, preloadAvatarGifs]);
  661. // 监听 storage(跨标签页同步当前形象)
  662. useEffect(() => {
  663. const handleStorage = (event: StorageEvent) => {
  664. if (event.key === STORAGE_KEYS.CURRENT_AVATAR_ID) {
  665. setCurrentAvatarId(event.newValue);
  666. }
  667. if (event.key === STORAGE_KEYS.IS_LOGGED_IN && event.newValue === 'false') {
  668. // 游客回退默认形象
  669. setCurrentAvatarId(AVATAR_ID_DEFAULT);
  670. }
  671. };
  672. window.addEventListener('storage', handleStorage);
  673. return () => {
  674. window.removeEventListener('storage', handleStorage);
  675. };
  676. }, []);
  677. // 监听路由变化,同步 localStorage 中的 currentAvatarId(处理从其他页面返回的情况)
  678. useEffect(() => {
  679. if (location.pathname === '/chatbot') {
  680. const storedAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
  681. // 只有当 localStorage 中的值与当前状态不一致时才更新
  682. if (storedAvatarId !== currentAvatarId) {
  683. debugLog(`[ChatBotPage] 检测到 localStorage 中的形象ID变化: ${currentAvatarId} -> ${storedAvatarId}`);
  684. setCurrentAvatarId(storedAvatarId);
  685. // 立即更新显示的 GIF,使用最新的 avatarGifSets
  686. const newGifSet = resolveAvatarGifSet(storedAvatarId);
  687. debugLog(`[ChatBotPage] 更新显示的 GIF: ${newGifSet.default}`);
  688. setCurrentAvatarGif(newGifSet.default);
  689. }
  690. }
  691. // eslint-disable-next-line react-hooks/exhaustive-deps
  692. }, [location.pathname]);
  693. // 当形象变化时立即更新显示 GIF
  694. useEffect(() => {
  695. showDefaultGif();
  696. }, [avatarGifSet, showDefaultGif]);
  697. const syncUserLevel = useCallback((level: number) => {
  698. setUserLevel(level);
  699. setUserLevelValue(level);
  700. }, []);
  701. const unlockAvatarC = useCallback(() => {
  702. if (avatarCUnlockedRef.current) {
  703. return;
  704. }
  705. avatarCUnlockedRef.current = true;
  706. setIsAvatarCUnlocked(true);
  707. setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
  708. const userId = getUserId();
  709. if (userId) {
  710. updateUserUIState(userId, {
  711. avatar_c_unlocked: true,
  712. }).catch(() => {
  713. // 同步失败不影响主流程
  714. });
  715. }
  716. }, []);
  717. const emitStoneEvent = useCallback(
  718. (stoneId: string, event: 'click' | 'timeout', position: EnergyStoneEventPayload) => {
  719. if (!wsRef.current) {
  720. return;
  721. }
  722. wsRef.current.send({
  723. type: 'energy_stone_event',
  724. requestId: stoneId,
  725. stoneId,
  726. event,
  727. clientTs: Date.now(),
  728. position,
  729. });
  730. },
  731. []
  732. );
  733. const resolveStoneSize = useCallback(
  734. (_stoneType: 'mission' | 'conversation') => {
  735. // 设计稿统一尺寸 48px,基于 vw/vh 最小值
  736. return 48;
  737. },
  738. []
  739. );
  740. // 创建一个隐藏的容器元素用于 useEnergyStones hook 计算位置
  741. const hiddenEnergyStoneZoneRef = useRef<HTMLDivElement>(null);
  742. const { stones: energyStones, spawnStone, handleStoneClick } = useEnergyStones(hiddenEnergyStoneZoneRef, {
  743. onEvent: emitStoneEvent,
  744. resolveStoneSize,
  745. });
  746. // 当能量石开始消失时,设置动画的起点和终点(处理超时自动消失的情况)
  747. useEffect(() => {
  748. const timers: number[] = [];
  749. const resetAnimation = (element?: HTMLElement | null) => {
  750. if (!element) {
  751. return;
  752. }
  753. element.style.animation = 'none';
  754. // 强制重排,确保重新读取起点坐标
  755. void element.offsetWidth;
  756. element.style.animation = '';
  757. };
  758. energyStones.forEach((stone) => {
  759. const container = document.querySelector(
  760. `[data-stone-id="${stone.stoneId}"]`
  761. ) as HTMLElement | null;
  762. // 非消失状态,移除就绪标记,避免错误启动动画
  763. if (!stone.fading) {
  764. container?.removeAttribute('data-anim-ready');
  765. return;
  766. }
  767. // 使用 setTimeout 确保 DOM 已更新
  768. const timer = window.setTimeout(() => {
  769. const target = document.querySelector(
  770. `[data-stone-id="${stone.stoneId}"]`
  771. ) as HTMLElement | null;
  772. if (!target) {
  773. return;
  774. }
  775. const rect = target.getBoundingClientRect();
  776. const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
  777. const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
  778. // 计算终点位置(动画形象中心,距离顶部60%位置)
  779. const viewportWidth = window.innerWidth;
  780. const viewportHeight = window.innerHeight;
  781. // 计算终点 top 位置(像素)
  782. const endTopPx = Math.min(
  783. (452 * Math.min(viewportWidth, 600) / 375),
  784. (452 / 812) * viewportHeight
  785. );
  786. const endLeft = viewportWidth / 2; // 50% 位置(像素)
  787. const dx = endLeft - startLeft;
  788. const dy = endTopPx - startTop;
  789. // 设置 CSS 变量,用于动画(使用像素值)
  790. target.style.setProperty('--animation-start-left', `${startLeft}px`);
  791. target.style.setProperty('--animation-start-top', `${startTop}px`);
  792. target.style.setProperty('--animation-end-left', `${endLeft}px`);
  793. target.style.setProperty('--animation-end-top', `${endTopPx}px`);
  794. target.style.setProperty('--animation-dx', `${dx}px`);
  795. target.style.setProperty('--animation-dy', `${dy}px`);
  796. // 强制写入动画样式,避免某些终端上样式覆盖/未生效
  797. target.style.left = `${startLeft}px`;
  798. target.style.top = `${startTop}px`;
  799. target.style.transform = 'translate(-50%, -50%) translate3d(0, 0, 0)';
  800. target.style.animation = 'energyStoneMoveToAvatar 1s ease-in-out forwards';
  801. target.style.willChange = 'transform, opacity';
  802. target.style.position = 'fixed';
  803. target.style.pointerEvents = 'none';
  804. runStoneAnimation(target, dx, dy);
  805. // 标记动画就绪,允许 CSS 启动
  806. target.setAttribute('data-anim-ready', 'true');
  807. // 重新启动动画,确保使用更新后的起点
  808. resetAnimation(target);
  809. resetAnimation(target.querySelector('.energy-stone') as HTMLElement | null);
  810. resetAnimation(target.querySelector('.energy-stone-hint') as HTMLElement | null);
  811. }, 0);
  812. timers.push(timer);
  813. });
  814. return () => {
  815. timers.forEach((timer) => {
  816. window.clearTimeout(timer);
  817. });
  818. };
  819. }, [energyStones, runStoneAnimation]);
  820. const startStoneAnimationAndRemove = useCallback(
  821. (stoneId: string) => {
  822. const container = document.querySelector(
  823. `[data-stone-id="${stoneId}"]`
  824. ) as HTMLElement | null;
  825. if (container) {
  826. const rect = container.getBoundingClientRect();
  827. const startLeft = rect.left + rect.width / 2;
  828. const startTop = rect.top + rect.height / 2;
  829. const viewportWidth = window.innerWidth;
  830. const viewportHeight = window.innerHeight;
  831. const endTopPx = Math.min(
  832. (452 * Math.min(viewportWidth, 600) / 375),
  833. (452 / 812) * viewportHeight
  834. );
  835. const endLeft = viewportWidth / 2;
  836. const dx = endLeft - startLeft;
  837. const dy = endTopPx - startTop;
  838. container.style.setProperty('--animation-start-left', `${startLeft}px`);
  839. container.style.setProperty('--animation-start-top', `${startTop}px`);
  840. container.style.setProperty('--animation-end-left', `${endLeft}px`);
  841. container.style.setProperty('--animation-end-top', `${endTopPx}px`);
  842. container.style.setProperty('--animation-dx', `${dx}px`);
  843. container.style.setProperty('--animation-dy', `${dy}px`);
  844. container.style.left = `${startLeft}px`;
  845. container.style.top = `${startTop}px`;
  846. container.style.transform = 'translate(-50%, -50%) translate3d(0, 0, 0)';
  847. container.style.animation = 'energyStoneMoveToAvatar 1s ease-in-out forwards';
  848. container.style.willChange = 'transform, opacity';
  849. container.style.position = 'fixed';
  850. container.style.pointerEvents = 'none';
  851. runStoneAnimation(container, dx, dy);
  852. }
  853. handleStoneClick(stoneId);
  854. // 能量石被点击后,刷新一次顶部的对话能量石数量(从数据库读取最新值)
  855. const uid = getUserId();
  856. if (uid) {
  857. fetchConversationStoneCount(uid);
  858. }
  859. },
  860. [handleStoneClick, runStoneAnimation, fetchConversationStoneCount]
  861. );
  862. const handleMissionStoneModalClose = useCallback(() => {
  863. setShowMissionStoneModal(false);
  864. const pendingStoneId = pendingStoneIdRef.current;
  865. if (pendingStoneId) {
  866. pendingStoneIdRef.current = null;
  867. startStoneAnimationAndRemove(pendingStoneId);
  868. }
  869. }, [startStoneAnimationAndRemove]);
  870. // 能量石展示区域:宽高 & 位置按设计稿 375×812 固定,以 px2vw/px2vh/px2vmin 适配
  871. const stopRecordingTimer = useCallback((options?: { preserveCountdown?: boolean }) => {
  872. if (recordingTimerRef.current) {
  873. clearInterval(recordingTimerRef.current);
  874. recordingTimerRef.current = null;
  875. }
  876. recordingDeadlineRef.current = 0;
  877. if (!options?.preserveCountdown) {
  878. setRemainingTimeMs(null);
  879. }
  880. }, []);
  881. const startRecordingTimer = useCallback(() => {
  882. stopRecordingTimer();
  883. recordingDeadlineRef.current = Date.now() + MAX_RECORDING_DURATION_MS;
  884. setRemainingTimeMs(MAX_RECORDING_DURATION_MS);
  885. recordingTimerRef.current = window.setInterval(() => {
  886. const remaining = recordingDeadlineRef.current - Date.now();
  887. if (remaining <= 0) {
  888. setRemainingTimeMs(0);
  889. stopRecordingTimer({ preserveCountdown: true });
  890. if (useAppStore.getState().currentState === 'recording') {
  891. handleSendRef.current?.();
  892. }
  893. return;
  894. }
  895. setRemainingTimeMs(remaining);
  896. }, 250);
  897. }, [stopRecordingTimer]);
  898. useEffect(() => {
  899. return () => {
  900. stopRecordingTimer();
  901. };
  902. }, [stopRecordingTimer]);
  903. useEffect(() => {
  904. if (currentState !== 'recording') {
  905. stopRecordingTimer();
  906. }
  907. }, [currentState, stopRecordingTimer]);
  908. /** 录音 50~60 秒已用时间显示:00:50、00:51 … 01:00(仅在剩余 ≤10 秒时用) */
  909. const formatElapsed50to60 = (remainingMs: number): string => {
  910. const elapsedSeconds = Math.min(60, Math.max(50, Math.floor((MAX_RECORDING_DURATION_MS - remainingMs) / 1000)));
  911. const minutes = Math.floor(elapsedSeconds / 60);
  912. const seconds = elapsedSeconds % 60;
  913. return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
  914. };
  915. // 进度动画效果(支持多圈连续动画)
  916. useEffect(() => {
  917. const targetTotal = progressLoopCount * 100 + conversationProgress;
  918. // 🔍 日志:记录 useEffect 触发时的状态
  919. debugLog('[Progress] [useEffect] 触发:', {
  920. conversationProgress,
  921. progressLoopCount,
  922. targetTotal,
  923. prevProgressStateRef: { ...prevProgressStateRef.current },
  924. totalProgressRef: totalProgressRef.current,
  925. displayProgress,
  926. shouldSkipAnimation: shouldSkipProgressAnimationRef.current,
  927. hasActiveAnimation: !!animationFrameRef.current,
  928. });
  929. // 🔧 如果 shouldSkipProgressAnimation 为 true,说明处于初始化/同步阶段
  930. // 直接同步 ref 并返回,避免播放历史动画。标志由后续用户动作(如 handleSend)再置为 false。
  931. if (shouldSkipProgressAnimationRef.current) {
  932. debugLog('[Progress] [useEffect] 初始化/同步阶段,跳过动画并同步 ref');
  933. if (animationFrameRef.current) {
  934. cancelAnimationFrame(animationFrameRef.current);
  935. animationFrameRef.current = undefined;
  936. }
  937. prevProgressStateRef.current = {
  938. progress: conversationProgress,
  939. loopCount: progressLoopCount,
  940. };
  941. totalProgressRef.current = targetTotal;
  942. const normalized = ((targetTotal % 100) + 100) % 100;
  943. setDisplayProgress(normalized);
  944. return;
  945. }
  946. // 使用上一次保存的状态值作为起始值(确保动画起始值正确)
  947. // 这样可以避免 displayProgress 异步更新导致的时序问题
  948. let prevTotal = prevProgressStateRef.current.loopCount * 100 + prevProgressStateRef.current.progress;
  949. // 立即保存当前状态到 prevProgressStateRef(供下次动画使用)
  950. // 这样即使本次动画还没完成,新的状态更新也能正确工作
  951. const currentState = {
  952. progress: conversationProgress,
  953. loopCount: progressLoopCount,
  954. };
  955. // 如果 prevTotal 是初始值 0,但目标值已经大于 0,说明是首次使用或状态被重置
  956. // 此时应该使用 totalProgressRef.current 或 displayProgress 作为起始值
  957. // 但如果 shouldSkipProgressAnimation 为 true,说明是初始化,应该直接使用目标值作为起始值
  958. if (prevTotal === 0 && targetTotal > 0) {
  959. if (shouldSkipProgressAnimationRef.current) {
  960. // 初始化场景:直接使用目标值作为起始值,避免播放动画
  961. debugLog('[Progress] [useEffect] 初始化场景,使用目标值作为起始值,跳过动画');
  962. prevTotal = targetTotal;
  963. prevProgressStateRef.current = currentState;
  964. totalProgressRef.current = targetTotal;
  965. } else {
  966. debugLog('[Progress] [useEffect] 检测到初始值情况,使用 displayProgress 或 totalProgressRef');
  967. // 如果 totalProgressRef 有值,使用它;否则使用 displayProgress
  968. if (totalProgressRef.current > 0) {
  969. prevTotal = totalProgressRef.current;
  970. debugLog('[Progress] [useEffect] 使用 totalProgressRef:', totalProgressRef.current);
  971. } else {
  972. prevTotal = displayProgress;
  973. debugLog('[Progress] [useEffect] 使用 displayProgress:', displayProgress);
  974. }
  975. // 更新 prevProgressStateRef 为当前显示的状态
  976. prevProgressStateRef.current = {
  977. progress: displayProgress,
  978. loopCount: Math.floor(totalProgressRef.current / 100),
  979. };
  980. debugLog('[Progress] [useEffect] 更新 prevProgressStateRef 为:', prevProgressStateRef.current);
  981. }
  982. }
  983. // 始终使用上一次保存的状态作为起始值,而不是 totalProgressRef.current
  984. // 因为 totalProgressRef.current 可能在动画过程中,不准确
  985. let startTotal = prevTotal;
  986. // 如果 totalProgressRef 与上一次状态不一致,同步它
  987. // 这样可以确保 totalProgressRef 与起始值一致,避免动画跳变
  988. if (!shouldSkipProgressAnimationRef.current) {
  989. const diffFromPrev = Math.abs(totalProgressRef.current - prevTotal);
  990. debugLog('[Progress] [useEffect] 检查 totalProgressRef 同步:', {
  991. diffFromPrev,
  992. totalProgressRef: totalProgressRef.current,
  993. prevTotal,
  994. needSync: diffFromPrev > 1,
  995. });
  996. // 如果差异较大(超过1%),说明可能有动画在进行,需要取消它并同步起始值
  997. if (diffFromPrev > 1) {
  998. debugLog('[Progress] [useEffect] 取消旧动画并同步 totalProgressRef');
  999. // 取消可能正在进行的动画
  1000. if (animationFrameRef.current) {
  1001. cancelAnimationFrame(animationFrameRef.current);
  1002. animationFrameRef.current = undefined;
  1003. }
  1004. // 同步 totalProgressRef 到起始值
  1005. totalProgressRef.current = startTotal;
  1006. }
  1007. }
  1008. // 立即更新 prevProgressStateRef 为当前状态(供下次动画使用)
  1009. // 注意:这里保存的是本次动画的目标值,下次动画会使用这个值作为起始值
  1010. prevProgressStateRef.current = currentState;
  1011. const diff = targetTotal - startTotal;
  1012. debugLog('[Progress] [useEffect] 计算动画参数:', {
  1013. startTotal,
  1014. targetTotal,
  1015. diff,
  1016. willAnimate: Math.abs(diff) >= 0.01 && !shouldSkipProgressAnimationRef.current,
  1017. });
  1018. // 如果 diff 为 0 或非常小,直接同步,不播放动画
  1019. if (Math.abs(diff) < 0.01) {
  1020. debugLog('[Progress] [useEffect] 跳过动画(diff 太小):', {
  1021. diff,
  1022. startTotal,
  1023. targetTotal,
  1024. });
  1025. totalProgressRef.current = targetTotal;
  1026. const normalized = ((targetTotal % 100) + 100) % 100;
  1027. setDisplayProgress(normalized);
  1028. // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
  1029. return;
  1030. }
  1031. // 处理整数圈:如果 diff 接近 0 且是整数圈,播放完整一圈动画
  1032. let actualDiff = diff;
  1033. if (Math.abs(diff) < 0.1) {
  1034. // 判断是否是整数圈(目标值是 100 的整数倍)
  1035. const isIntegerLoop = Math.abs(targetTotal % 100) < 0.1;
  1036. if (isIntegerLoop && targetTotal > startTotal) {
  1037. // 整数圈增长,播放完整一圈动画
  1038. actualDiff = 100;
  1039. } else if (isIntegerLoop && targetTotal < startTotal) {
  1040. // 整数圈减少,播放完整一圈反向动画
  1041. actualDiff = -100;
  1042. } else {
  1043. // 非整数圈的微小变化,直接同步
  1044. totalProgressRef.current = targetTotal;
  1045. const normalized = ((targetTotal % 100) + 100) % 100;
  1046. setDisplayProgress(normalized);
  1047. // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
  1048. return;
  1049. }
  1050. }
  1051. // 每 1% 的进度约 8ms,限制在 0.4s~2.4s 之间
  1052. const durationPerPercent = 8;
  1053. const duration = Math.max(400, Math.min(2400, Math.abs(actualDiff) * durationPerPercent));
  1054. const startTime = performance.now();
  1055. debugLog('[Progress] [useEffect] 开始动画:', {
  1056. startTotal,
  1057. targetTotal,
  1058. actualDiff,
  1059. duration,
  1060. startTime,
  1061. });
  1062. if (animationFrameRef.current) {
  1063. debugLog('[Progress] [useEffect] 取消旧动画');
  1064. cancelAnimationFrame(animationFrameRef.current);
  1065. }
  1066. let animationFrameCount = 0;
  1067. const animate = (currentTime: number) => {
  1068. animationFrameCount++;
  1069. const elapsed = currentTime - startTime;
  1070. const progress = Math.min(elapsed / duration, 1);
  1071. // easeOutCubic 缓动
  1072. const easeProgress = 1 - Math.pow(1 - progress, 3);
  1073. const currentTotal = startTotal + actualDiff * easeProgress;
  1074. totalProgressRef.current = currentTotal;
  1075. const normalized = ((currentTotal % 100) + 100) % 100;
  1076. setDisplayProgress(normalized);
  1077. // 🔍 日志:每10帧记录一次动画进度
  1078. if (animationFrameCount % 10 === 0 || progress >= 1) {
  1079. debugLog('[Progress] [animate] 动画进度:', {
  1080. frame: animationFrameCount,
  1081. progress: (progress * 100).toFixed(2) + '%',
  1082. currentTotal: currentTotal.toFixed(2),
  1083. normalized: normalized.toFixed(2),
  1084. elapsed: elapsed.toFixed(2) + 'ms',
  1085. });
  1086. }
  1087. if (progress < 1) {
  1088. animationFrameRef.current = requestAnimationFrame(animate);
  1089. } else {
  1090. // 动画完成后,同步到实际的目标值
  1091. debugLog('[Progress] [animate] 动画完成:', {
  1092. finalTotal: targetTotal,
  1093. finalNormalized: ((targetTotal % 100) + 100) % 100,
  1094. totalFrames: animationFrameCount,
  1095. });
  1096. totalProgressRef.current = targetTotal;
  1097. const finalNormalized = ((targetTotal % 100) + 100) % 100;
  1098. setDisplayProgress(finalNormalized);
  1099. // prevProgressStateRef 已经在 useEffect 开始时更新了,这里不需要再次更新
  1100. }
  1101. };
  1102. animationFrameRef.current = requestAnimationFrame(animate);
  1103. return () => {
  1104. if (animationFrameRef.current) {
  1105. cancelAnimationFrame(animationFrameRef.current);
  1106. }
  1107. };
  1108. }, [conversationProgress, progressLoopCount]);
  1109. // 监听录音状态,首次显示取消提示(由 ENABLE_CHATBOT_TIPS 控制)
  1110. useEffect(() => {
  1111. if (!ENABLE_CHATBOT_TIPS) return;
  1112. let timer: number | undefined;
  1113. if (currentState === 'recording') {
  1114. const hasShownCancel = getStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP);
  1115. if (!hasShownCancel) {
  1116. timer = window.setTimeout(() => {
  1117. setShowCancelTooltip(true);
  1118. setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
  1119. const userId = getUserId();
  1120. if (userId) {
  1121. updateUserUIState(userId, {
  1122. has_shown_cancel_tooltip: true,
  1123. }).catch(() => {});
  1124. }
  1125. }, 200);
  1126. } else {
  1127. setShowCancelTooltip(false);
  1128. }
  1129. } else {
  1130. setShowCancelTooltip(false);
  1131. }
  1132. return () => {
  1133. if (timer !== undefined) window.clearTimeout(timer);
  1134. };
  1135. }, [currentState]);
  1136. // 监听录音状态,首次显示发送提示(由 ENABLE_CHATBOT_TIPS 控制)
  1137. useEffect(() => {
  1138. if (!ENABLE_CHATBOT_TIPS) return;
  1139. let timer: number | undefined;
  1140. if (currentState === 'recording') {
  1141. const hasShownSend = getStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP);
  1142. if (!hasShownSend) {
  1143. timer = window.setTimeout(() => {
  1144. setShowSendTooltip(true);
  1145. setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
  1146. const userId = getUserId();
  1147. if (userId) {
  1148. updateUserUIState(userId, {
  1149. has_shown_send_tooltip: true,
  1150. }).catch(() => {});
  1151. }
  1152. }, 200);
  1153. } else {
  1154. setShowSendTooltip(false);
  1155. }
  1156. } else {
  1157. setShowSendTooltip(false);
  1158. }
  1159. return () => {
  1160. if (timer !== undefined) window.clearTimeout(timer);
  1161. };
  1162. }, [currentState]);
  1163. const dismissProgressBarTooltip = useCallback(() => {
  1164. if (progressBarTooltipTimerRef.current) {
  1165. clearTimeout(progressBarTooltipTimerRef.current);
  1166. progressBarTooltipTimerRef.current = null;
  1167. }
  1168. if (progressBarTooltipRef.current) {
  1169. progressBarTooltipRef.current.style.opacity = '0';
  1170. progressBarTooltipRef.current.style.pointerEvents = 'none';
  1171. progressBarTooltipRef.current.style.display = 'none';
  1172. }
  1173. setShowProgressBarTooltip(false);
  1174. }, []);
  1175. useEffect(() => {
  1176. if (showProgressBarTooltip && progressBarTooltipRef.current) {
  1177. progressBarTooltipRef.current.style.display = 'block';
  1178. progressBarTooltipRef.current.style.opacity = '1';
  1179. progressBarTooltipRef.current.style.pointerEvents = 'none';
  1180. }
  1181. }, [showProgressBarTooltip]);
  1182. // 监听进度条变化,首次有进度时显示进度条提示(由 ENABLE_CHATBOT_TIPS 控制)
  1183. useEffect(() => {
  1184. if (!ENABLE_CHATBOT_TIPS) return;
  1185. if (
  1186. displayProgress > 0 &&
  1187. !hasShownProgressBarTooltip &&
  1188. isConnected &&
  1189. currentState !== 'recording'
  1190. ) {
  1191. setShowProgressBarTooltip(true);
  1192. setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
  1193. setHasShownProgressBarTooltip(true);
  1194. const userId = getUserId();
  1195. if (userId) {
  1196. updateUserUIState(userId, {
  1197. has_shown_progress_bar_tooltip: true,
  1198. }).catch(() => {});
  1199. }
  1200. }
  1201. }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
  1202. // 进度条提示显示后自动隐藏(2 秒)
  1203. useEffect(() => {
  1204. if (!showProgressBarTooltip) {
  1205. if (progressBarTooltipTimerRef.current) {
  1206. clearTimeout(progressBarTooltipTimerRef.current);
  1207. progressBarTooltipTimerRef.current = null;
  1208. }
  1209. return;
  1210. }
  1211. progressBarTooltipTimerRef.current = window.setTimeout(() => {
  1212. setShowProgressBarTooltip(false);
  1213. progressBarTooltipTimerRef.current = null;
  1214. }, 2000);
  1215. return () => {
  1216. if (progressBarTooltipTimerRef.current) {
  1217. clearTimeout(progressBarTooltipTimerRef.current);
  1218. progressBarTooltipTimerRef.current = null;
  1219. }
  1220. };
  1221. }, [showProgressBarTooltip]);
  1222. // 录音状态下强制隐藏进度条提示,避免在用户开始录音后残留
  1223. useEffect(() => {
  1224. if (currentState === 'recording') {
  1225. dismissProgressBarTooltip();
  1226. }
  1227. }, [currentState, dismissProgressBarTooltip]);
  1228. // 监听屏幕尺寸变化
  1229. useEffect(() => {
  1230. // 初始化时更新标记
  1231. updateTooSmallFlag();
  1232. const cleanup = onScreenResize(() => {
  1233. const newLayout = calculateLayout();
  1234. setLayout(newLayout); // V2: 更新完全计算式布局
  1235. // 更新 data-too-small 标记
  1236. updateTooSmallFlag();
  1237. });
  1238. return cleanup;
  1239. }, []);
  1240. // 同步背景图片状态的函数
  1241. const syncBackgroundImageState = useCallback(() => {
  1242. const bgUrl = getCurrentBackgroundImageUrl();
  1243. const bgId = getCurrentBackgroundImageId();
  1244. // 直接更新状态,React 会自动处理重复更新
  1245. setBackgroundImageUrl(bgUrl);
  1246. setBackgroundImageId(bgId);
  1247. }, []);
  1248. // 初始化背景图片(从 localStorage 读取)
  1249. useEffect(() => {
  1250. const bgUrl = getCurrentBackgroundImageUrl();
  1251. const bgId = getCurrentBackgroundImageId();
  1252. if (bgUrl) {
  1253. setBackgroundImageUrl(bgUrl);
  1254. }
  1255. if (bgId) {
  1256. setBackgroundImageId(bgId);
  1257. }
  1258. if (!bgUrl || !bgId) {
  1259. // 如果没有背景图片,尝试从用户信息同步
  1260. const user_id = getUserId();
  1261. if (user_id) {
  1262. getUserInfo(user_id).then((userInfo) => {
  1263. if (userInfo.success && userInfo.data?.current_background_image_id) {
  1264. syncBackgroundImageToStorage(
  1265. userInfo.data.current_background_image_id,
  1266. userInfo.data.current_background_image_metadata
  1267. );
  1268. const newBgUrl = getCurrentBackgroundImageUrl();
  1269. const newBgId = getCurrentBackgroundImageId();
  1270. if (newBgUrl) {
  1271. setBackgroundImageUrl(newBgUrl);
  1272. }
  1273. if (newBgId) {
  1274. setBackgroundImageId(newBgId);
  1275. }
  1276. }
  1277. }).catch(() => {
  1278. // 静默处理错误
  1279. });
  1280. }
  1281. }
  1282. }, []);
  1283. // 能量石点击后直接消失,不再需要监听 fading 状态
  1284. // 监听 localStorage 中背景图片的变化
  1285. useEffect(() => {
  1286. // 监听 storage 事件(其他标签页/窗口的变化)
  1287. const handleStorageChange = (e: StorageEvent) => {
  1288. if (e.key === STORAGE_KEYS.CURRENT_BACKGROUND_IMAGE_ID ||
  1289. e.key === STORAGE_KEYS.CURRENT_BACKGROUND_IMAGE_URL) {
  1290. syncBackgroundImageState();
  1291. }
  1292. };
  1293. // 监听页面可见性变化(当页面重新获得焦点时检查)
  1294. const handleVisibilityChange = () => {
  1295. if (!document.hidden) {
  1296. syncBackgroundImageState();
  1297. }
  1298. };
  1299. // 定期检查背景图片变化(作为后备机制,每5秒检查一次)
  1300. const intervalId = setInterval(() => {
  1301. syncBackgroundImageState();
  1302. }, 5000);
  1303. window.addEventListener('storage', handleStorageChange);
  1304. document.addEventListener('visibilitychange', handleVisibilityChange);
  1305. return () => {
  1306. window.removeEventListener('storage', handleStorageChange);
  1307. document.removeEventListener('visibilitychange', handleVisibilityChange);
  1308. clearInterval(intervalId);
  1309. };
  1310. }, [syncBackgroundImageState]);
  1311. // 处理OAuth登录回调(QQ登录成功后直接跳转到chatbot)
  1312. useEffect(() => {
  1313. const oauth = searchParams.get('oauth');
  1314. const userId = searchParams.get('user_id');
  1315. const token = searchParams.get('token');
  1316. // 如果 URL 中有 OAuth 回调参数,说明是登录成功回调
  1317. // ✅ 确保 user_id 和 token 已设置到 localStorage(双重保险,避免时序问题)
  1318. if (oauth === 'qq' && userId) {
  1319. console.log(`[ChatBotPage] 🔧 检测到 QQ OAuth 回调,确保参数已设置到 localStorage - user_id=${userId}, token=${token ? '已提供' : '未提供'}`);
  1320. // 1. 确保用户 ID 已设置(同步操作)
  1321. setStorage(STORAGE_KEYS.USER_ID, userId);
  1322. // 2. 如果 URL 中有 token,确保已设置(同步操作)
  1323. if (token) {
  1324. setUserToken(token);
  1325. console.log(`[ChatBotPage] ✅ 已设置 token 到 localStorage`);
  1326. }
  1327. // 3. 确保登录状态已设置(同步操作)
  1328. setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
  1329. console.log(`[ChatBotPage] ✅ QQ OAuth 回调参数已全部设置到 localStorage`);
  1330. antMessage.success(tCommon('qqLoginSuccess'));
  1331. // 清除URL参数(延迟清除,确保 UserGuard 能读取到参数)
  1332. setTimeout(() => {
  1333. navigate('/chatbot', { replace: true });
  1334. }, 100);
  1335. }
  1336. }, [searchParams, navigate]);
  1337. // 初始化
  1338. useEffect(() => {
  1339. // 🔧 立即设置 shouldSkipProgressAnimation = true,避免在异步加载完成前触发动画
  1340. // 这可以防止从其他页面返回时播放历史动画
  1341. shouldSkipProgressAnimationRef.current = true;
  1342. debugLog('[Progress] [初始化] 组件挂载,设置 shouldSkipProgressAnimation = true');
  1343. // 记录用户当前所在页面
  1344. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
  1345. // 记录路由访问
  1346. markRouteVisited('/chatbot');
  1347. const visited = getVisitedRoutes();
  1348. const userId = getUserId();
  1349. if (userId) {
  1350. // 从数据库加载所有用户信息到 localStorage
  1351. // 1. 先加载用户基本信息(getUserInfo)
  1352. // 2. 再加载用户 UI 状态(getUserUIState)
  1353. Promise.all([
  1354. getUserInfo(userId).catch(() => null),
  1355. getUserUIState(userId).catch(() => null),
  1356. ]).then(([userInfoResult, uiStateResult]) => {
  1357. // 加载用户基本信息到 localStorage
  1358. if (userInfoResult?.success && userInfoResult.data) {
  1359. const userInfo = userInfoResult.data;
  1360. // Lyrria_user_name
  1361. if (userInfo.username) {
  1362. setUserName(userInfo.username);
  1363. }
  1364. // Lyrria_lumi_name
  1365. if (userInfo.bot_name) {
  1366. setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.bot_name);
  1367. }
  1368. // Lyrria_user_level
  1369. if (typeof userInfo.user_level === 'number') {
  1370. syncUserLevel(userInfo.user_level);
  1371. }
  1372. // Lyrria_is_logged_in (根据 user_type 判断:registered 为已登录)
  1373. if (userInfo.user_type === 'registered') {
  1374. setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
  1375. } else {
  1376. setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
  1377. }
  1378. // Lyrria_current_background_image_id, Lyrria_current_background_image_url, Lyrria_current_background_image_opacity
  1379. if (userInfo.current_background_image_id) {
  1380. syncBackgroundImageToStorage(
  1381. userInfo.current_background_image_id,
  1382. userInfo.current_background_image_metadata
  1383. );
  1384. const bgUrl = getCurrentBackgroundImageUrl();
  1385. const bgId = getCurrentBackgroundImageId();
  1386. if (bgUrl) {
  1387. setBackgroundImageUrl(bgUrl);
  1388. }
  1389. if (bgId) {
  1390. setBackgroundImageId(bgId);
  1391. }
  1392. }
  1393. // 初始化进度条(基于 total_speech_duration_ms)
  1394. const totalDurationMs = userInfo.total_speech_duration_ms ?? 0;
  1395. if (totalDurationMs > 0) {
  1396. const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
  1397. totalSpeechDurationRef.current = Math.max(
  1398. totalDurationMs,
  1399. avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
  1400. );
  1401. if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
  1402. unlockAvatarC();
  1403. }
  1404. const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
  1405. const initialProgress = totalProgressPercent % 100;
  1406. const initialLoopCount = Math.floor(totalProgressPercent / 100);
  1407. debugLog('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (Promise.all)', {
  1408. totalDurationMs,
  1409. totalProgressPercent,
  1410. initialProgress,
  1411. initialLoopCount,
  1412. });
  1413. // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
  1414. prevProgressStateRef.current = {
  1415. progress: initialProgress,
  1416. loopCount: initialLoopCount,
  1417. };
  1418. totalProgressRef.current = initialLoopCount * 100 + initialProgress;
  1419. shouldSkipProgressAnimationRef.current = true;
  1420. // 设置正确的圈数,而不是重置为 0
  1421. setProgress(initialProgress, initialLoopCount);
  1422. }
  1423. }
  1424. // 加载用户 UI 状态到 localStorage
  1425. if (uiStateResult?.success && uiStateResult.data) {
  1426. const state = uiStateResult.data;
  1427. // Lyrria_current_avatar_id
  1428. if (state.current_avatar_id) {
  1429. setCurrentAvatarId(state.current_avatar_id);
  1430. setStorage(STORAGE_KEYS.CURRENT_AVATAR_ID, state.current_avatar_id);
  1431. }
  1432. // Lyrria_current_page
  1433. if (state.current_page) {
  1434. setStorage(STORAGE_KEYS.CURRENT_PAGE, state.current_page);
  1435. }
  1436. // Lyrria_visited_routes (合并本地和数据库的路由,避免覆盖本地刚记录的路由)
  1437. const localVisited = getVisitedRoutes();
  1438. if (Array.isArray(state.visited_routes)) {
  1439. // 合并本地和数据库的路由,去重并保持顺序
  1440. const mergedRoutes = [...new Set([...localVisited, ...state.visited_routes])];
  1441. // 按照 ROUTE_ORDER 的顺序排序
  1442. const sortedRoutes = ROUTE_ORDER.filter(route => mergedRoutes.includes(route));
  1443. setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(sortedRoutes));
  1444. } else if (state.visited_routes === null || state.visited_routes === undefined) {
  1445. // 如果数据库中是 null 或 undefined,保留本地已有的路由
  1446. if (localVisited.length > 0) {
  1447. setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(localVisited));
  1448. } else {
  1449. setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
  1450. }
  1451. }
  1452. // Lyrria_has_shown_tap_to_chat
  1453. if (state.has_shown_tap_to_chat) {
  1454. setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
  1455. if (isConnected) {
  1456. setShowTapToChat(false);
  1457. }
  1458. }
  1459. // Lyrria_has_shown_cancel_tooltip
  1460. if (state.has_shown_cancel_tooltip) {
  1461. setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
  1462. setShowCancelTooltip(false);
  1463. }
  1464. // Lyrria_has_shown_send_tooltip
  1465. if (state.has_shown_send_tooltip) {
  1466. setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
  1467. setShowSendTooltip(false);
  1468. }
  1469. // Lyrria_has_shown_progress_bar_tooltip
  1470. if (state.has_shown_progress_bar_tooltip) {
  1471. setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
  1472. setHasShownProgressBarTooltip(true);
  1473. }
  1474. // Lyrria_avatar_c_unlocked
  1475. if (state.avatar_c_unlocked && !avatarCUnlockedRef.current) {
  1476. avatarCUnlockedRef.current = true;
  1477. setIsAvatarCUnlocked(true);
  1478. setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
  1479. }
  1480. // Lyrria_has_shared_avatar (无论 true/false,都从数据库同步)
  1481. setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, state.has_shared_avatar ? 'true' : 'false');
  1482. setHasSharedAvatarState(state.has_shared_avatar);
  1483. // 字幕开关偏好(无论 true/false,都从数据库同步)
  1484. if (typeof state.subtitle_on === 'boolean') {
  1485. setStorage(STORAGE_KEYS.SUBTITLE_ON, state.subtitle_on ? 'true' : 'false');
  1486. setSubtitleOn(state.subtitle_on);
  1487. }
  1488. // Lyrria_has_shown_mission_stone_modal
  1489. if (state.has_shown_mission_stone_modal) {
  1490. setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
  1491. }
  1492. // Lyrria_has_shown_conversation_stone_modal
  1493. if (state.has_shown_conversation_stone_modal) {
  1494. setStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL, 'true');
  1495. }
  1496. // Lyrria_has_clicked_mission_stone
  1497. if (state.has_clicked_mission_stone) {
  1498. setStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE, 'true');
  1499. setHasClickedMissionStone(true);
  1500. }
  1501. // Lyrria_has_clicked_conversation_stone
  1502. if (state.has_clicked_conversation_stone) {
  1503. setStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE, 'true');
  1504. setHasClickedConversationStone(true);
  1505. }
  1506. // 补偿同步:如果本地记录为 true 但后端为 false,尝试补偿同步
  1507. const localMissionShown = getStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL) === 'true';
  1508. if (localMissionShown && !state.has_shown_mission_stone_modal) {
  1509. updateUserUIState(userId, {
  1510. has_shown_mission_stone_modal: true,
  1511. }).catch(() => {
  1512. // 静默处理
  1513. });
  1514. }
  1515. const localConversationShown =
  1516. getStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL) === 'true';
  1517. if (localConversationShown && !state.has_shown_conversation_stone_modal) {
  1518. updateUserUIState(userId, {
  1519. has_shown_conversation_stone_modal: true,
  1520. }).catch(() => {
  1521. // 静默处理
  1522. });
  1523. }
  1524. const localMissionClicked = getStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE) === 'true';
  1525. if (localMissionClicked && !state.has_clicked_mission_stone) {
  1526. updateUserUIState(userId, {
  1527. has_clicked_mission_stone: true,
  1528. }).catch(() => {
  1529. // 静默处理
  1530. });
  1531. }
  1532. const localConversationClicked = getStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE) === 'true';
  1533. if (localConversationClicked && !state.has_clicked_conversation_stone) {
  1534. updateUserUIState(userId, {
  1535. has_clicked_conversation_stone: true,
  1536. }).catch(() => {
  1537. // 静默处理
  1538. });
  1539. }
  1540. }
  1541. }).catch(() => {
  1542. // 加载失败不影响主流程
  1543. console.warn('[ChatBotPage] 从数据库加载用户信息失败');
  1544. });
  1545. // 同步当前页面与已访问路由
  1546. updateUserUIState(userId, {
  1547. current_page: 'chatbot',
  1548. visited_routes: visited,
  1549. }).catch(() => {
  1550. // 同步失败不影响主流程
  1551. });
  1552. // 对话能量石数量(顶部状态)
  1553. fetchConversationStoneCount(userId);
  1554. // 亲密度:不满 3 颗心且当天未点亮时在 /chatbot 展示弹窗
  1555. Promise.allSettled([getIntimacy(userId), getIntimacyStatus(userId)]).then((results) => {
  1556. const intimacyRes =
  1557. results[0].status === 'fulfilled' ? results[0].value : undefined;
  1558. const statusRes =
  1559. results[1].status === 'fulfilled' ? results[1].value : undefined;
  1560. if (intimacyRes?.success && intimacyRes.data) {
  1561. setIntimacyHeartCount(intimacyRes.data.heart_count);
  1562. setIntimacyDaysSinceLastVisit(intimacyRes.data.days_since_last_visit);
  1563. }
  1564. if (statusRes?.success && statusRes.data) {
  1565. setCanLightUpToday(statusRes.data.can_light_up_today);
  1566. }
  1567. const heartCount =
  1568. intimacyRes?.data?.heart_count ?? statusRes?.data?.heart_count ?? 3;
  1569. const canLight =
  1570. statusRes?.data?.can_light_up_today ?? heartCount < 3;
  1571. if (heartCount < 3 && canLight) {
  1572. // 有亲密度弹窗时,先展示弹窗,等用户关闭后再触发首句 greeting
  1573. setShowIntimacyModal(true);
  1574. } else {
  1575. // 无需展示亲密度弹窗时,直接在聊天页初始化完成后触发首句 greeting
  1576. startGreeting();
  1577. }
  1578. });
  1579. } else {
  1580. // 没有 userId(如游客态)时,不走亲密度流程,直接尝试触发首句 greeting
  1581. startGreeting();
  1582. }
  1583. const init = async () => {
  1584. try {
  1585. // 获取用户信息并初始化进度条(已在上面 Promise.all 中处理,这里保留作为兜底)
  1586. const user_id = getUserId();
  1587. if (user_id) {
  1588. try {
  1589. const userInfo = await getUserInfo(user_id);
  1590. if (userInfo.success && userInfo.data) {
  1591. // 同步用户名到 localStorage
  1592. if (userInfo.data.username) {
  1593. setUserName(userInfo.data.username);
  1594. }
  1595. // 同步机器人名字到 localStorage
  1596. if (userInfo.data.bot_name) {
  1597. setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
  1598. }
  1599. // 同步用户等级
  1600. const totalDurationMs = userInfo.data.total_speech_duration_ms ?? 0;
  1601. if (typeof userInfo.data.user_level === 'number') {
  1602. syncUserLevel(userInfo.data.user_level);
  1603. }
  1604. // 同步背景图片
  1605. if (userInfo.data.current_background_image_id) {
  1606. syncBackgroundImageToStorage(
  1607. userInfo.data.current_background_image_id,
  1608. userInfo.data.current_background_image_metadata
  1609. );
  1610. const bgUrl = getCurrentBackgroundImageUrl();
  1611. const bgId = getCurrentBackgroundImageId();
  1612. if (bgUrl) {
  1613. setBackgroundImageUrl(bgUrl);
  1614. }
  1615. if (bgId) {
  1616. setBackgroundImageId(bgId);
  1617. }
  1618. }
  1619. if (totalDurationMs > 0) {
  1620. // 根据总说话时长计算初始进度(30秒=100%)
  1621. const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
  1622. totalSpeechDurationRef.current = Math.max(
  1623. totalDurationMs,
  1624. avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
  1625. );
  1626. if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
  1627. unlockAvatarC();
  1628. }
  1629. // 计算总进度百分比和圈数
  1630. const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
  1631. const initialProgress = totalProgressPercent % 100;
  1632. const initialLoopCount = Math.floor(totalProgressPercent / 100);
  1633. debugLog('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (init)', {
  1634. totalDurationMs,
  1635. totalProgressPercent,
  1636. initialProgress,
  1637. initialLoopCount,
  1638. });
  1639. // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
  1640. prevProgressStateRef.current = {
  1641. progress: initialProgress,
  1642. loopCount: initialLoopCount,
  1643. };
  1644. totalProgressRef.current = initialLoopCount * 100 + initialProgress;
  1645. shouldSkipProgressAnimationRef.current = true;
  1646. // 设置正确的圈数,而不是重置为 0
  1647. setProgress(initialProgress, initialLoopCount);
  1648. }
  1649. }
  1650. } catch (error) {
  1651. // 获取用户信息失败不影响主流程,静默处理
  1652. console.warn('获取用户信息失败:', error);
  1653. }
  1654. }
  1655. // 初始化录音器
  1656. const recorder = new AudioRecorder();
  1657. await recorder.initialize();
  1658. recorderRef.current = recorder;
  1659. // 初始化播放器
  1660. const player = new AudioPlayer();
  1661. await player.initialize();
  1662. // 音频即将结束前的回调:提前结束情绪/说话 GIF,避免尾部静音导致动画拖尾
  1663. player.onBeforeEnd(() => {
  1664. const snapshot = useAppStore.getState();
  1665. debugLog('[AudioPlayer] onBeforeEnd: audio about to finish, early stop GIF', {
  1666. currentState: snapshot.currentState,
  1667. currentRequestId: snapshot.currentRequestId
  1668. ? snapshot.currentRequestId.substring(0, 8)
  1669. : null,
  1670. });
  1671. // 本次对话即将结束,提前清理情绪状态和 GIF 切换定时器
  1672. pendingEmotionRef.current = null;
  1673. if (emotionGifTimerRef.current) {
  1674. clearTimeout(emotionGifTimerRef.current);
  1675. emotionGifTimerRef.current = null;
  1676. }
  1677. // 提前切回默认头像 GIF,立即生效
  1678. flushSync(() => {
  1679. showDefaultGifRef.current?.();
  1680. });
  1681. });
  1682. // 音频开始播放回调:统一驱动情绪 GIF + talking GIF + 同步字幕显示
  1683. player.onStart(() => {
  1684. const appStateSnapshot = useAppStore.getState();
  1685. const now = Date.now();
  1686. debugLog('[AudioPlayer] 音频开始播放,准备切换情绪/说话 GIF', {
  1687. currentState: appStateSnapshot.currentState,
  1688. currentRequestId: appStateSnapshot.currentRequestId
  1689. ? appStateSnapshot.currentRequestId.substring(0, 8)
  1690. : null,
  1691. currentEmotion: appStateSnapshot.currentEmotion,
  1692. clientNow: now,
  1693. });
  1694. // 优先使用缓存的情绪(不在此处清空,在确定展示后再清,避免“情感延迟”时误判为 neutral 先播 talking)
  1695. const cachedEmotion = pendingEmotionRef.current;
  1696. const latestEmotion = cachedEmotion || appStateSnapshot.currentEmotion;
  1697. const emotionCategory = getEmotionCategory(latestEmotion);
  1698. debugLog('[AudioPlayer] onStart emotion category resolved', {
  1699. cachedEmotion,
  1700. latestEmotion,
  1701. emotionCategory,
  1702. });
  1703. // 任何新的播放开始都应清理旧的情绪定时器,避免串台
  1704. if (emotionGifTimerRef.current) {
  1705. clearTimeout(emotionGifTimerRef.current);
  1706. emotionGifTimerRef.current = null;
  1707. debugLog('[AudioPlayer] onStart: cleared previous emotionGifTimer');
  1708. }
  1709. // 仅在字幕开关打开时展示字幕;用户语音不会触发此回调,因此不会显示为字幕
  1710. if (subtitleOnRef.current && appStateSnapshot.currentState === 'playing') {
  1711. setSubtitleVisible(true);
  1712. } else {
  1713. setSubtitleVisible(false);
  1714. }
  1715. // 用 flushSync 立即提交 GIF 状态,避免 React 批处理导致动画晚于语音 0.5~1s
  1716. const flushShowTalking = () => flushSync(() => showTalkingGifRef.current?.());
  1717. const flushShowPositive = () => flushSync(() => showPositiveGifRef.current?.());
  1718. const flushShowNegative = () => flushSync(() => showNegativeGifRef.current?.());
  1719. // 强制限定:本轮已播放过情感 GIF 则只播 talking(仅点击录音时重置,不在 onEnd 重置)
  1720. if (emotionGifPlayedThisRoundRef.current) {
  1721. debugLog('[AudioPlayer] onStart: 本轮已播放过情感 GIF,直接 talking');
  1722. pendingEmotionRef.current = null;
  1723. flushShowTalking();
  1724. return;
  1725. }
  1726. // 若本段已显示过情绪 GIF(保证一次对话只播一次情感 GIF),后续只切 talking
  1727. if (emotionGifShownForStreamRef.current) {
  1728. debugLog('[AudioPlayer] onStart: 本段已显示过情绪 GIF,直接 talking');
  1729. pendingEmotionRef.current = null;
  1730. flushShowTalking();
  1731. return;
  1732. }
  1733. // 已有明确正向/负向:先标记本轮已播情感(防重复),再播情感 GIF,定时结束后切 talking
  1734. if (emotionCategory === 'positive') {
  1735. emotionGifPlayedThisRoundRef.current = true;
  1736. emotionGifShownForStreamRef.current = true;
  1737. pendingEmotionRef.current = null;
  1738. flushShowPositive();
  1739. const duration = getEmotionGifDuration(null, 'positive');
  1740. emotionGifTimerRef.current = window.setTimeout(() => {
  1741. emotionGifTimerRef.current = null;
  1742. flushShowTalking();
  1743. debugLog('[AudioPlayer] onStart: 情感 GIF 定时结束,切 talking');
  1744. }, duration);
  1745. debugLog('[AudioPlayer] onStart: 已播正向情感 GIF,将在', duration, 'ms 后切 talking');
  1746. return;
  1747. }
  1748. if (emotionCategory === 'negative') {
  1749. emotionGifPlayedThisRoundRef.current = true;
  1750. emotionGifShownForStreamRef.current = true;
  1751. pendingEmotionRef.current = null;
  1752. flushShowNegative();
  1753. const duration = getEmotionGifDuration(null, 'negative');
  1754. emotionGifTimerRef.current = window.setTimeout(() => {
  1755. emotionGifTimerRef.current = null;
  1756. flushShowTalking();
  1757. debugLog('[AudioPlayer] onStart: 情感 GIF 定时结束,切 talking');
  1758. }, duration);
  1759. debugLog('[AudioPlayer] onStart: 已播负向情感 GIF,将在', duration, 'ms 后切 talking');
  1760. return;
  1761. }
  1762. // 当前为 neutral:若尚未收到本轮情感(cached 为空),可能情感消息稍晚,延迟一帧再决定,避免先 talking 再情感再 talking
  1763. if (cachedEmotion === null) {
  1764. requestAnimationFrame(() => {
  1765. const cached = pendingEmotionRef.current;
  1766. const latest = cached ?? useAppStore.getState().currentEmotion;
  1767. const category = getEmotionCategory(latest);
  1768. pendingEmotionRef.current = null;
  1769. if (emotionGifShownForStreamRef.current) {
  1770. flushShowTalking();
  1771. return;
  1772. }
  1773. if (category === 'positive') {
  1774. emotionGifPlayedThisRoundRef.current = true;
  1775. emotionGifShownForStreamRef.current = true;
  1776. flushShowPositive();
  1777. const duration = getEmotionGifDuration(null, 'positive');
  1778. emotionGifTimerRef.current = window.setTimeout(() => {
  1779. emotionGifTimerRef.current = null;
  1780. flushShowTalking();
  1781. debugLog('[AudioPlayer] onStart: 情感 GIF 定时结束,切 talking');
  1782. }, duration);
  1783. return;
  1784. }
  1785. if (category === 'negative') {
  1786. emotionGifPlayedThisRoundRef.current = true;
  1787. emotionGifShownForStreamRef.current = true;
  1788. flushShowNegative();
  1789. const duration = getEmotionGifDuration(null, 'negative');
  1790. emotionGifTimerRef.current = window.setTimeout(() => {
  1791. emotionGifTimerRef.current = null;
  1792. flushShowTalking();
  1793. debugLog('[AudioPlayer] onStart: 情感 GIF 定时结束,切 talking');
  1794. }, duration);
  1795. return;
  1796. }
  1797. emotionGifShownForStreamRef.current = true;
  1798. flushShowTalking();
  1799. });
  1800. return;
  1801. }
  1802. // 已收到情感且为中性,直接 talking
  1803. pendingEmotionRef.current = null;
  1804. emotionGifShownForStreamRef.current = true;
  1805. flushShowTalking();
  1806. });
  1807. // 音频播放结束回调:强制回归 default,本次对话收尾 + 隐藏字幕
  1808. player.onEnd(() => {
  1809. const snapshot = useAppStore.getState();
  1810. const requestId = snapshot.currentRequestId;
  1811. debugLog('[AudioPlayer] onEnd: audio playback finished', {
  1812. currentState: snapshot.currentState,
  1813. currentRequestId: requestId ? requestId.substring(0, 8) : null,
  1814. });
  1815. if (requestId) {
  1816. performanceLogger.recordPlaybackEnd(requestId);
  1817. }
  1818. // 本次对话已结束,清理待处理情绪
  1819. pendingEmotionRef.current = null;
  1820. // 清理情绪 GIF 切换定时器,避免定时器稍后再次切回 talking
  1821. if (emotionGifTimerRef.current) {
  1822. clearTimeout(emotionGifTimerRef.current);
  1823. emotionGifTimerRef.current = null;
  1824. debugLog('[AudioPlayer] onEnd: cleared emotionGifTimer and reset to default GIF');
  1825. }
  1826. flushSync(() => {
  1827. showDefaultGifRef.current?.();
  1828. });
  1829. // 播放完成后隐藏字幕并清空字幕文本
  1830. setSubtitleVisible(false);
  1831. setRecognizedText('');
  1832. subtitleAccumulatorRequestIdRef.current = null;
  1833. // 不在 onEnd 里清 responseCompleteRequestIdRef,否则 onEnd 之后才到的同轮滞后 chunk 会再次触发 onStart
  1834. streamStartedForRequestIdRef.current = null; // 下一轮新 request 的首包可再次触发 onStart
  1835. emotionGifShownForStreamRef.current = false;
  1836. setState('idle');
  1837. reset();
  1838. });
  1839. playerRef.current = player;
  1840. // 初始化WebSocket前确保会话已就绪(支持用户直接刷新/直达聊天页)
  1841. // 🔧 如果 URL 中有 OAuth 回调参数,说明正在处理登录回调,不调用 ensureSession(避免创建新用户)
  1842. const oauthFromUrl = searchParams.get('oauth');
  1843. const userIdFromUrl = searchParams.get('user_id');
  1844. if (!oauthFromUrl || !userIdFromUrl) {
  1845. // 只有在非 OAuth 回调时才调用 ensureSession
  1846. try {
  1847. await ensureSession();
  1848. } catch (error) {
  1849. console.error('[ChatBotPage] ensureSession 失败,无法初始化 WebSocket:', error);
  1850. clearUserSessionAndRedirectToBegin();
  1851. return;
  1852. }
  1853. } else {
  1854. console.log(`[ChatBotPage] 检测到 OAuth 回调,跳过 ensureSession - oauth=${oauthFromUrl}, user_id=${userIdFromUrl}`);
  1855. }
  1856. // 初始化WebSocket(添加 user_id + token 查询参数,单活)
  1857. const ws = new WebSocketManager();
  1858. const token = getUserToken();
  1859. const params = new URLSearchParams();
  1860. if (user_id) {
  1861. params.append('user_id', user_id);
  1862. }
  1863. if (token) {
  1864. params.append('token', token);
  1865. }
  1866. ws.setAuth(
  1867. { userId: user_id || undefined, token: token || undefined },
  1868. async () => {
  1869. // 服务端明确标记会话失效时(如在其他设备登录),直接清理本地状态并跳转到 /begin
  1870. clearUserSessionAndRedirectToBegin();
  1871. return false;
  1872. }
  1873. );
  1874. const wsUrl = params.toString() ? `${WS_URL}?${params.toString()}` : WS_URL;
  1875. ws.connect(wsUrl, (connected) => {
  1876. // 如果组件已卸载,不执行任何操作
  1877. if (!isMountedRef.current) {
  1878. return;
  1879. }
  1880. const appState = useAppStore.getState();
  1881. const wasConnected = appState.isConnected;
  1882. setConnected(connected);
  1883. if (connected) {
  1884. // 记录WebSocket连接建立时间
  1885. performanceLogger.recordWebSocketConnected();
  1886. // 如果这是首次连接成功,处理 TapToChat 提示
  1887. if (!connectionLostRef.current) {
  1888. const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
  1889. if (!hasShown && showTapToChat) {
  1890. // 第一次连接成功,保存标记
  1891. setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
  1892. // 同步到后端 UI 状态(如果已有 user_id)
  1893. const userId = getUserId();
  1894. if (userId) {
  1895. updateUserUIState(userId, {
  1896. has_shown_tap_to_chat: true,
  1897. }).catch(() => {
  1898. // 同步失败不影响前端提示展示
  1899. });
  1900. }
  1901. } else if (hasShown) {
  1902. // 如果已经标记为已显示(可能是从后端同步的),现在 WebSocket 已连接,可以安全隐藏
  1903. setShowTapToChat(false);
  1904. }
  1905. }
  1906. // 如果在 WebSocket 初始化完成之前已经根据亲密度逻辑决定需要触发首句 greeting,
  1907. // 则在连接成功且未被亲密度弹窗/用户录音拦截时,补发一次 start_greeting。
  1908. if (
  1909. pendingGreetingRef.current &&
  1910. !hasGreetingStartedRef.current &&
  1911. !greetingSuppressedRef.current &&
  1912. !delayGreetingRef.current
  1913. ) {
  1914. debugLog('[首句] WebSocket 连接成功且存在待发送的首句 greeting,立即触发 start_greeting');
  1915. startGreeting();
  1916. }
  1917. if (connectionLostRef.current) {
  1918. antMessage.success(tCommon('voiceServiceRestored'));
  1919. connectionLostRef.current = false;
  1920. recorderRef.current?.stop();
  1921. resetFrequencyVisualization();
  1922. playerRef.current?.stop();
  1923. // 清理解码中的情绪状态与 GIF 切换定时器
  1924. pendingEmotionRef.current = null;
  1925. if (emotionGifTimerRef.current) {
  1926. clearTimeout(emotionGifTimerRef.current);
  1927. emotionGifTimerRef.current = null;
  1928. }
  1929. // 恢复默认头像 GIF
  1930. showDefaultGif();
  1931. reset();
  1932. setError(null);
  1933. }
  1934. return;
  1935. }
  1936. if (!wasConnected) {
  1937. // 初次连接失败,静默等待自动重连
  1938. return;
  1939. }
  1940. connectionLostRef.current = true;
  1941. if (appState.currentRequestId) {
  1942. performanceLogger.clearRequest(appState.currentRequestId, 'websocket_disconnected');
  1943. }
  1944. recorderRef.current?.stop();
  1945. resetFrequencyVisualization();
  1946. playerRef.current?.stop();
  1947. // 清理解码中的情绪状态与 GIF 切换定时器
  1948. pendingEmotionRef.current = null;
  1949. if (emotionGifTimerRef.current) {
  1950. clearTimeout(emotionGifTimerRef.current);
  1951. emotionGifTimerRef.current = null;
  1952. }
  1953. // 恢复默认头像 GIF
  1954. showDefaultGif();
  1955. antMessage.error(tCommon('voiceServiceDisconnected'));
  1956. setError(tCommon('voiceServiceDisconnected'));
  1957. });
  1958. ws.onMessage('app', handleMessage);
  1959. wsRef.current = ws;
  1960. } catch (error) {
  1961. antMessage.error(tCommon('initFailedCheckMic'));
  1962. setError(tCommon('initFailedCheckMic'));
  1963. }
  1964. };
  1965. init();
  1966. return () => {
  1967. // 标记组件已卸载,防止 WebSocket 回调显示错误消息
  1968. isMountedRef.current = false;
  1969. recorderRef.current?.release();
  1970. playerRef.current?.release();
  1971. wsRef.current?.disconnect();
  1972. resetFrequencyVisualization();
  1973. if (sendingTimeoutRef.current) {
  1974. clearTimeout(sendingTimeoutRef.current);
  1975. }
  1976. if (animationFrameRef.current) {
  1977. cancelAnimationFrame(animationFrameRef.current);
  1978. }
  1979. if (progressBarTooltipTimerRef.current) {
  1980. clearTimeout(progressBarTooltipTimerRef.current);
  1981. }
  1982. if (emotionGifTimerRef.current) {
  1983. clearTimeout(emotionGifTimerRef.current);
  1984. }
  1985. // 组件卸载时不再保留任何待处理情绪,避免内存中残留过期状态
  1986. pendingEmotionRef.current = null;
  1987. };
  1988. }, [resetFrequencyVisualization]);
  1989. // 处理WebSocket消息
  1990. const handleMessage = (message: Message) => {
  1991. const requestId = message.requestId || currentRequestId;
  1992. if (!requestId) {
  1993. console.warn('[ChatBotPage] 收到缺少 requestId 的消息,已忽略', message.type);
  1994. return;
  1995. }
  1996. const activeRequestId = activeRequestIdRef.current;
  1997. const isCancelledRequest = cancelledRequestIdsRef.current.has(requestId);
  1998. // 🔥 首句消息特殊处理(requestId 以 "greeting_" 开头)
  1999. const isGreetingMessage = requestId.startsWith('greeting_');
  2000. // 如果亲密度弹窗正在展示,则不处理任何首句相关消息(文本/情绪/音频)
  2001. if (isGreetingMessage && delayGreetingRef.current) {
  2002. debugLog(
  2003. `[首句] [${requestId.substring(
  2004. 0,
  2005. 8,
  2006. )}] 亲密度弹窗展示中,忽略首句消息: ${message.type}`,
  2007. );
  2008. return;
  2009. }
  2010. // 记录首句 requestId,便于后续在开始录音时发送 cancel
  2011. if (isGreetingMessage && !greetingRequestIdRef.current) {
  2012. greetingRequestIdRef.current = requestId;
  2013. }
  2014. // 如果用户已经开始录音且选择“以用户为准”,则直接丢弃所有首句相关消息
  2015. if (isGreetingMessage && greetingSuppressedRef.current) {
  2016. debugLog(`[首句] [${requestId.substring(0, 8)}] 已被用户录音打断,忽略首句消息: ${message.type}`);
  2017. return;
  2018. }
  2019. if (isCancelledRequest && !isGreetingMessage) {
  2020. if (message.type === 'audio_response' && message.isLast) {
  2021. cancelledRequestIdsRef.current.delete(requestId);
  2022. }
  2023. debugLog(`[${message.type}] [${requestId.substring(0, 8)}] 已取消的请求,忽略响应`);
  2024. return;
  2025. }
  2026. switch (message.type) {
  2027. case 'text_result': {
  2028. // 仅拼接 AI 返回的文本到字幕;用户 STT(source=user)不参与字幕
  2029. if (message.source === 'user') {
  2030. break;
  2031. }
  2032. const cleaned = cleanSubtitleText(message.text);
  2033. // 🔥 首句消息:直接处理,不检查 activeRequestId
  2034. if (isGreetingMessage) {
  2035. if (message.isFinal) {
  2036. debugLog(`[首句文本] [${requestId.substring(0, 8)}] ${cleaned}`);
  2037. if (subtitleAccumulatorRequestIdRef.current !== requestId) {
  2038. subtitleAccumulatorRequestIdRef.current = requestId;
  2039. setRecognizedText(cleaned);
  2040. } else {
  2041. const prev = useAppStore.getState().recognizedText;
  2042. setRecognizedText(prev ? `${prev} ${cleaned}` : cleaned);
  2043. }
  2044. }
  2045. break;
  2046. }
  2047. if (activeRequestId !== null && requestId !== activeRequestId) {
  2048. debugLog(`[文本结果] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的文本结果`);
  2049. break;
  2050. }
  2051. if (message.isFinal) {
  2052. performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
  2053. } else if (!performanceLogger.hasFirstText(requestId)) {
  2054. performanceLogger.recordFirstText(requestId, message.text, message.timestamp);
  2055. }
  2056. // 多段音频文字拼接:同一次对话(同一 requestId)追加,新请求则替换
  2057. if (subtitleAccumulatorRequestIdRef.current !== requestId) {
  2058. subtitleAccumulatorRequestIdRef.current = requestId;
  2059. setRecognizedText(cleaned);
  2060. } else {
  2061. const prev = useAppStore.getState().recognizedText;
  2062. setRecognizedText(prev ? `${prev} ${cleaned}` : cleaned);
  2063. }
  2064. break;
  2065. }
  2066. case 'user_level':
  2067. if (typeof message.level === 'number') {
  2068. syncUserLevel(message.level);
  2069. }
  2070. // 🔥 使用后端返回的 totalDurationMs 同步进度条,确保与数据库一致
  2071. if (typeof message.totalDurationMs === 'number' && message.totalDurationMs > 0) {
  2072. const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
  2073. // 满足解锁阈值时实时解锁,避免必须刷新页面
  2074. if (message.totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS && !avatarCUnlockedRef.current) {
  2075. unlockAvatarC();
  2076. }
  2077. // 更新本地累加器,确保与数据库一致
  2078. totalSpeechDurationRef.current = Math.max(
  2079. message.totalDurationMs,
  2080. avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
  2081. );
  2082. // 根据总时长计算进度条位置(只显示非整数圈的进度)
  2083. const totalProgressPercent = (message.totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
  2084. const progress = totalProgressPercent % 100;
  2085. // 🔍 检查当前状态,如果用户刚刚更新了进度,不要覆盖用户的更新
  2086. const currentState = useAppStore.getState();
  2087. const currentTotal = currentState.progressLoopCount * 100 + currentState.conversationProgress;
  2088. const newTotal = Math.floor(totalProgressPercent / 100) * 100 + progress;
  2089. debugLog('[Progress] [user_level] 收到后端同步请求', {
  2090. totalDurationMs: message.totalDurationMs,
  2091. progress,
  2092. currentTotal,
  2093. newTotal,
  2094. requestId: requestId?.substring(0, 8),
  2095. shouldSync: Math.abs(currentTotal - newTotal) > 0.1, // 只有差异较大时才同步
  2096. });
  2097. // 只有在差异较大时才同步(避免覆盖用户刚刚的更新)
  2098. if (Math.abs(currentTotal - newTotal) > 0.1) {
  2099. // 计算应该保持的圈数(基于总时长)
  2100. const targetLoopCount = Math.floor(totalProgressPercent / 100);
  2101. // 只在初始化时跳过动画(如果当前进度接近0)
  2102. const isInitialSync = currentTotal < 1;
  2103. if (isInitialSync) {
  2104. debugLog('[Progress] [user_level] 初始化同步,跳过动画但同步进度');
  2105. shouldSkipProgressAnimationRef.current = true;
  2106. }
  2107. // 无论是否初始化,都同步进度值,动画是否播放由 shouldSkipProgressAnimationRef 控制
  2108. debugLog('[Progress] [user_level] 同步进度,设置进度值:', {
  2109. progress,
  2110. targetLoopCount,
  2111. });
  2112. // 保持当前的圈数,不重置为0
  2113. setProgress(progress, targetLoopCount);
  2114. } else {
  2115. debugLog('[Progress] [user_level] 差异太小,跳过同步');
  2116. }
  2117. }
  2118. break;
  2119. case 'emotion':
  2120. // 🔥 首句消息:直接处理,不检查 activeRequestId
  2121. if (isGreetingMessage) {
  2122. performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
  2123. setEmotion(message.emotion);
  2124. // 🔥 仅缓存情绪,不立即切换 GIF,等待首句音频真正开始播放时再驱动动画
  2125. pendingEmotionRef.current = message.emotion;
  2126. break;
  2127. }
  2128. // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
  2129. // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
  2130. if (activeRequestId !== null && requestId !== activeRequestId) {
  2131. debugLog(`[情绪] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的情绪`);
  2132. break;
  2133. }
  2134. performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
  2135. setEmotion(message.emotion);
  2136. // 🔥 普通情绪消息同样只缓存,真正的情绪动画在音频开始播放时统一处理
  2137. pendingEmotionRef.current = message.emotion;
  2138. break;
  2139. case 'audio_response':
  2140. // 🔥 首句消息:直接处理,不检查状态和 activeRequestId
  2141. if (isGreetingMessage) {
  2142. // 首句音频:自动播放
  2143. if (currentState !== 'playing') {
  2144. const now = Date.now();
  2145. emotionGifShownForStreamRef.current = false; // 新流开始,允许本段显示情绪 GIF
  2146. debugLog(`[音频响应][首句] [${requestId.substring(0, 8)}] 首次音频块,进入 playing 状态`, {
  2147. messageTimestamp: message.timestamp,
  2148. clientNow: now,
  2149. delayMs: typeof message.timestamp === 'number' ? now - message.timestamp : null,
  2150. });
  2151. performanceLogger.recordFirstAudio(requestId, message.timestamp);
  2152. performanceLogger.recordPlaybackStart(requestId);
  2153. setState('playing');
  2154. }
  2155. // 标记当前处于首句播放阶段,便于后续开始录音时精确打断
  2156. isGreetingActiveRef.current = true;
  2157. if (message.data && playerRef.current) {
  2158. if (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current !== requestId) {
  2159. responseCompleteRequestIdRef.current = null;
  2160. }
  2161. const audioData = atob(message.data);
  2162. const audioBytes = new Uint8Array(audioData.length);
  2163. for (let i = 0; i < audioData.length; i++) {
  2164. audioBytes[i] = audioData.charCodeAt(i);
  2165. }
  2166. // 同一 request 只对「首包」触发 onStart,后续包(含第二段、第三段音频)一律 skipStreamReset
  2167. const skipReset =
  2168. streamStartedForRequestIdRef.current === requestId ||
  2169. (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current === requestId);
  2170. playerRef.current.addChunk(audioBytes, message.sampleRate || 24000, { skipStreamReset: skipReset });
  2171. if (!skipReset) streamStartedForRequestIdRef.current = requestId;
  2172. }
  2173. if (message.isLast) {
  2174. responseCompleteRequestIdRef.current = requestId;
  2175. performanceLogger.recordLastAudio(requestId);
  2176. playerRef.current?.markStreamEnd();
  2177. isGreetingActiveRef.current = false;
  2178. greetingRequestIdRef.current = null;
  2179. }
  2180. break;
  2181. }
  2182. // 如果当前状态是 recording,说明用户已经点击了录音按钮,忽略后续的音频响应
  2183. if (currentState === 'recording') {
  2184. debugLog(`[音频响应] [${requestId.substring(0, 8)}] 用户已开始录音,忽略音频响应`);
  2185. break;
  2186. }
  2187. // 如果 currentRequestId 不为 null,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
  2188. // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
  2189. if (activeRequestId !== null && requestId !== activeRequestId) {
  2190. debugLog(`[音频响应] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的音频响应`, {
  2191. activeRequestId: activeRequestId ? activeRequestId.substring(0, 8) : null,
  2192. messageIsLast: message.isLast,
  2193. });
  2194. break;
  2195. }
  2196. // 清除发送超时
  2197. if (sendingTimeoutRef.current) {
  2198. clearTimeout(sendingTimeoutRef.current);
  2199. sendingTimeoutRef.current = null;
  2200. }
  2201. if (currentState !== 'playing') {
  2202. const now = Date.now();
  2203. emotionGifShownForStreamRef.current = false; // 新流开始,允许本段显示情绪 GIF
  2204. debugLog(`[音频响应] [${requestId.substring(0, 8)}] 首次音频块,进入 playing 状态`, {
  2205. messageTimestamp: message.timestamp,
  2206. clientNow: now,
  2207. delayMs: typeof message.timestamp === 'number' ? now - message.timestamp : null,
  2208. });
  2209. performanceLogger.recordFirstAudio(requestId, message.timestamp);
  2210. performanceLogger.recordPlaybackStart(requestId);
  2211. setState('playing');
  2212. }
  2213. if (message.data) {
  2214. if (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current !== requestId) {
  2215. responseCompleteRequestIdRef.current = null;
  2216. }
  2217. const audioData = atob(message.data);
  2218. const audioBytes = new Uint8Array(audioData.length);
  2219. for (let i = 0; i < audioData.length; i++) {
  2220. audioBytes[i] = audioData.charCodeAt(i);
  2221. }
  2222. debugLog(`[音频响应] [${requestId.substring(0, 8)}] 收到音频数据块`, {
  2223. bytes: audioBytes.length,
  2224. sampleRate: message.sampleRate || 24000,
  2225. isLast: message.isLast,
  2226. });
  2227. // 同一 request 只对「首包」触发 onStart,后续包(第二段、第三段音频及滞后 chunk)一律 skipStreamReset
  2228. const skipReset =
  2229. streamStartedForRequestIdRef.current === requestId ||
  2230. (responseCompleteRequestIdRef.current !== null && responseCompleteRequestIdRef.current === requestId);
  2231. playerRef.current?.addChunk(audioBytes, message.sampleRate || 24000, {
  2232. skipStreamReset: skipReset,
  2233. });
  2234. if (!skipReset) streamStartedForRequestIdRef.current = requestId;
  2235. }
  2236. if (message.isLast) {
  2237. // 整个 AI 返回结束:仅在此刻标记流结束;对话结束(idle)由 onEnd 在全部播完后触发
  2238. responseCompleteRequestIdRef.current = requestId;
  2239. debugLog(`[音频响应] [${requestId.substring(0, 8)}] 收到 isLast=true,标记音频流结束`);
  2240. performanceLogger.recordLastAudio(requestId);
  2241. playerRef.current?.markStreamEnd();
  2242. // 进度已在发送时增加,这里不再重复增加
  2243. }
  2244. break;
  2245. case 'energy_stone_spawn':
  2246. handleEnergyStoneSpawn(message);
  2247. break;
  2248. case 'error':
  2249. console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
  2250. // 静默处理后端错误提示,避免打扰用户
  2251. debugLog('[ServiceError]', message.message, message.code);
  2252. setError(message.message);
  2253. if (message.code === 'SESSION_INVALIDATED') {
  2254. // 会话在其他设备被顶掉:清理会话并统一跳转到 /begin
  2255. wsRef.current?.disconnect();
  2256. antMessage.error(tCommon('sessionLoggedInElsewhere'));
  2257. clearUserSessionAndRedirectToBegin();
  2258. return;
  2259. }
  2260. // 清理情绪 GIF 切换定时器
  2261. if (emotionGifTimerRef.current) {
  2262. clearTimeout(emotionGifTimerRef.current);
  2263. emotionGifTimerRef.current = null;
  2264. }
  2265. // 恢复默认头像 GIF
  2266. showDefaultGif();
  2267. performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
  2268. break;
  2269. case 'pong':
  2270. break;
  2271. default:
  2272. console.warn(`[未知消息] [${requestId?.substring(0, 8) || ''}]`, message.type);
  2273. break;
  2274. }
  2275. };
  2276. // 开始录音
  2277. const handleStartRecord = async () => {
  2278. setShowFuncMenuPopup(false);
  2279. // 每轮录音开始前将对话层形象置为 default,避免上一轮 talking 残留(不用 key 防闪烁)
  2280. // 用 flushSync 同步提交,否则立即点击时与 setState('recording') 被批量更新,对话层仍用上一帧 talking 渲染
  2281. flushSync(() => {
  2282. showDefaultGifRef.current?.();
  2283. });
  2284. // 如果正在录音,忽略
  2285. if (currentState === 'recording') {
  2286. return;
  2287. }
  2288. // 🔥 改进:允许在连接建立过程中开始录音
  2289. // 1. 如果 WebSocket 不存在,说明还未初始化,提示等待
  2290. // 2. 如果 WebSocket 存在,检查是否正在连接中或已就绪
  2291. // 3. 如果正在连接中(CONNECTING)或已就绪(OPEN),允许开始录音,消息会被缓冲
  2292. // 4. 这样用户无感知,业务也能正常运行
  2293. if (!wsRef.current) {
  2294. antMessage.warning(tCommon('voiceServiceInitializing'));
  2295. return;
  2296. }
  2297. // 检查 WebSocket 状态:如果正在连接中或已就绪,允许开始录音
  2298. const isConnecting = wsRef.current.isConnecting();
  2299. const isReady = wsRef.current.isReady();
  2300. // 如果既不在连接中,也不已就绪,且 isConnected 为 false,说明连接失败
  2301. if (!isConnecting && !isReady && !isConnected) {
  2302. // 静默提示:语音服务重连中
  2303. debugLog('[SendGuard] 语音服务正在重连');
  2304. return;
  2305. }
  2306. // 如果正在连接中或已就绪,允许开始录音(消息会被缓冲)
  2307. // 保存旧请求ID(用于取消和过滤旧消息)
  2308. const oldRequestId = currentRequestId;
  2309. const wasPlaying = currentState === 'playing' || currentState === 'sending';
  2310. // 如果正在播放,先停止播放并重置状态
  2311. if (wasPlaying) {
  2312. // 停止音频播放(包括可能正在播放的首句)
  2313. playerRef.current?.stop();
  2314. // 清理情绪 GIF 切换定时器
  2315. if (emotionGifTimerRef.current) {
  2316. clearTimeout(emotionGifTimerRef.current);
  2317. emotionGifTimerRef.current = null;
  2318. }
  2319. // 立即恢复默认头像 GIF
  2320. showDefaultGif();
  2321. // 取消当前的请求(如果有),丢弃上一次请求的返回
  2322. if (oldRequestId) {
  2323. wsRef.current?.sendCancel(oldRequestId);
  2324. cancelledRequestIdsRef.current.add(oldRequestId);
  2325. performanceLogger.clearRequest(oldRequestId, '用户中断播放开始新录音');
  2326. }
  2327. // 清理旧请求的状态(丢弃上一次请求的返回)
  2328. setRecognizedText('');
  2329. subtitleAccumulatorRequestIdRef.current = null;
  2330. setEmotion('neutral');
  2331. // 清理发送超时
  2332. if (sendingTimeoutRef.current) {
  2333. clearTimeout(sendingTimeoutRef.current);
  2334. sendingTimeoutRef.current = null;
  2335. }
  2336. }
  2337. // 其他状态(idle/error/sending)都允许开始录音
  2338. // 一旦用户主动开始录音,则以用户为准:后续所有首句 greeting 消息全部丢弃
  2339. greetingSuppressedRef.current = true;
  2340. // 如果此时已经拿到首句的 requestId,则主动向后端发送 cancel,节省资源
  2341. if (greetingRequestIdRef.current) {
  2342. const greetingId = greetingRequestIdRef.current;
  2343. wsRef.current?.sendCancel(greetingId);
  2344. cancelledRequestIdsRef.current.add(greetingId);
  2345. debugLog(`[首句] [${greetingId.substring(0, 8)}] 用户开始录音,发送 cancel 终止首句`);
  2346. }
  2347. // 首次点击录音按钮,隐藏 TapToChat 提示并保存标记
  2348. if (showTapToChat) {
  2349. setShowTapToChat(false);
  2350. // 确保标记已保存(防止 WebSocket 连接较慢时未保存)
  2351. const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
  2352. if (!hasShown) {
  2353. setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
  2354. // 同步到后端 UI 状态(如果已有 user_id)
  2355. const userId = getUserId();
  2356. if (userId) {
  2357. updateUserUIState(userId, {
  2358. has_shown_tap_to_chat: true,
  2359. }).catch(() => {
  2360. // 同步失败不影响主流程
  2361. });
  2362. }
  2363. }
  2364. }
  2365. // 点击录音按钮时,隐藏进度条提示
  2366. dismissProgressBarTooltip();
  2367. // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
  2368. playerRef.current?.stop();
  2369. // 生成新的请求ID并立即设置,确保后续消息能正确匹配
  2370. const requestId = uuidv4();
  2371. performanceLogger.startRequest(requestId);
  2372. updateRequestId(requestId);
  2373. setState('recording');
  2374. chunkIndexRef.current = 0;
  2375. setRecognizedText('');
  2376. subtitleAccumulatorRequestIdRef.current = null;
  2377. // 新一轮对话开始,允许本次 AI 回复播报时再播放一次情感 GIF
  2378. emotionGifShownForStreamRef.current = false;
  2379. emotionGifPlayedThisRoundRef.current = false;
  2380. // 准备移动端音频(在设置状态之后,避免阻塞状态更新)
  2381. // 🔥 改进:静默准备音频环境,不显示提示,用户无感知
  2382. if (isMobileDevice() && !mobileAudioPreparedRef.current) {
  2383. // 异步准备,不阻塞录音开始
  2384. prepareMobileAudio()
  2385. .then(() => {
  2386. requestVolumeControl();
  2387. mobileAudioPreparedRef.current = true;
  2388. // 移除提示,让用户无感知,业务功能正常(prepareMobileAudio 和 requestVolumeControl 仍会执行)
  2389. })
  2390. .catch(() => {
  2391. // 准备移动端音频失败,静默处理
  2392. });
  2393. }
  2394. timingRef.current = {
  2395. recordStart: Date.now(),
  2396. recordEnd: 0,
  2397. firstAudioReceived: 0,
  2398. emotionReceived: 0,
  2399. };
  2400. // 立即启动录音,确保第一次点击就能开始
  2401. try {
  2402. await recorderRef.current?.start((pcmData) => {
  2403. // 检查当前状态,如果已经不是recording,说明用户可能又点击了,忽略
  2404. const currentStateCheck = useAppStore.getState().currentState;
  2405. if (currentStateCheck !== 'recording') {
  2406. return;
  2407. }
  2408. if (chunkIndexRef.current === 0) {
  2409. performanceLogger.recordFirstChunkSent(requestId);
  2410. }
  2411. const uint8Array = new Uint8Array(pcmData.buffer);
  2412. const base64 = btoa(String.fromCharCode(...Array.from(uint8Array)));
  2413. wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
  2414. chunkIndexRef.current++;
  2415. }, pushFrequencyFrame);
  2416. // 录音启动成功,开始计时
  2417. startRecordingTimer();
  2418. } catch (error) {
  2419. console.error(`[录音] [${requestId.substring(0, 8)}] 启动失败`, error);
  2420. antMessage.error(tCommon('micStartFailed'));
  2421. performanceLogger.clearRequest(requestId, 'recorder_start_failed');
  2422. resetFrequencyVisualization();
  2423. // 清理解码中的情绪状态与 GIF 切换定时器
  2424. pendingEmotionRef.current = null;
  2425. if (emotionGifTimerRef.current) {
  2426. clearTimeout(emotionGifTimerRef.current);
  2427. emotionGifTimerRef.current = null;
  2428. }
  2429. // 恢复默认头像 GIF
  2430. showDefaultGif();
  2431. setState('idle');
  2432. reset();
  2433. return;
  2434. }
  2435. };
  2436. // 发送录音
  2437. const handleSend = () => {
  2438. if (currentState !== 'recording' || !currentRequestId) {
  2439. return;
  2440. }
  2441. stopRecordingTimer();
  2442. // 🔥 改进:允许在连接建立过程中发送录音
  2443. // 1. 如果 WebSocket 不存在,说明还未初始化,提示错误
  2444. // 2. 如果 WebSocket 存在,检查是否正在连接中或已就绪
  2445. // 3. 如果正在连接中(CONNECTING)或已就绪(OPEN),允许发送,消息会被缓冲
  2446. // 4. 这样用户无感知,业务也能正常运行
  2447. if (!wsRef.current) {
  2448. // 静默处理:语音服务初始化中
  2449. debugLog('[SendGuard] 语音服务初始化中');
  2450. recorderRef.current?.stop();
  2451. resetFrequencyVisualization();
  2452. // 清理解码中的情绪状态与 GIF 切换定时器
  2453. pendingEmotionRef.current = null;
  2454. if (emotionGifTimerRef.current) {
  2455. clearTimeout(emotionGifTimerRef.current);
  2456. emotionGifTimerRef.current = null;
  2457. }
  2458. // 恢复默认头像 GIF
  2459. showDefaultGif();
  2460. performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
  2461. reset();
  2462. return;
  2463. }
  2464. // 检查 WebSocket 状态:如果正在连接中或已就绪,允许发送
  2465. const isConnecting = wsRef.current.isConnecting();
  2466. const isReady = wsRef.current.isReady();
  2467. // 如果既不在连接中,也不已就绪,且 isConnected 为 false,说明连接失败
  2468. if (!isConnecting && !isReady && !isConnected) {
  2469. // 静默处理:语音服务未连接
  2470. debugLog('[SendGuard] 语音服务未连接');
  2471. recorderRef.current?.stop();
  2472. resetFrequencyVisualization();
  2473. // 清理解码中的情绪状态与 GIF 切换定时器
  2474. pendingEmotionRef.current = null;
  2475. if (emotionGifTimerRef.current) {
  2476. clearTimeout(emotionGifTimerRef.current);
  2477. emotionGifTimerRef.current = null;
  2478. }
  2479. // 恢复默认头像 GIF
  2480. showDefaultGif();
  2481. performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
  2482. reset();
  2483. return;
  2484. }
  2485. // 如果正在连接中或已就绪,允许发送(消息会被缓冲)
  2486. timingRef.current.recordEnd = Date.now();
  2487. sendTimeRef.current = Date.now();
  2488. performanceLogger.recordEnd(currentRequestId, chunkIndexRef.current);
  2489. // 计算说话时长(毫秒)
  2490. const speechDuration = timingRef.current.recordEnd - timingRef.current.recordStart;
  2491. // 进度条与累计说话时长以后端为准:
  2492. // 这里只负责将本次说话时长发送给后端,不在前端进行乐观更新,
  2493. // 后端在成功处理并持久化 total_speech_duration_ms 后,会通过 totalDurationMs 同步最新进度。
  2494. if (speechDuration > 0 && shouldSkipProgressAnimationRef.current) {
  2495. debugLog('[Progress] [handleSend] 初始化阶段结束,后续进度同步以后端为准', {
  2496. speechDurationMs: speechDuration,
  2497. });
  2498. shouldSkipProgressAnimationRef.current = false;
  2499. }
  2500. recorderRef.current?.stop();
  2501. resetFrequencyVisualization();
  2502. // 发送音频结束消息,包含说话时长信息
  2503. wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current, speechDuration);
  2504. setState('sending');
  2505. // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置
  2506. if (sendingTimeoutRef.current) {
  2507. clearTimeout(sendingTimeoutRef.current);
  2508. }
  2509. sendingTimeoutRef.current = window.setTimeout(() => {
  2510. const state = useAppStore.getState().currentState;
  2511. if (state === 'sending' || state === 'playing') {
  2512. const shortId = currentRequestId ? currentRequestId.substring(0, 8) : '';
  2513. console.warn(`[超时] [${shortId}] 响应超时,自动重置`);
  2514. // 静默处理超时提示
  2515. debugLog('[Timeout] 响应超时,自动重置');
  2516. // 清理解码中的情绪状态与 GIF 切换定时器
  2517. pendingEmotionRef.current = null;
  2518. if (emotionGifTimerRef.current) {
  2519. clearTimeout(emotionGifTimerRef.current);
  2520. emotionGifTimerRef.current = null;
  2521. }
  2522. // 恢复默认头像 GIF
  2523. showDefaultGif();
  2524. setState('idle');
  2525. reset();
  2526. performanceLogger.clearRequest(currentRequestId, '响应超时');
  2527. }
  2528. }, 10000);
  2529. };
  2530. handleSendRef.current = handleSend;
  2531. // 取消录音
  2532. const handleCancel = () => {
  2533. if (currentState !== 'recording' || !currentRequestId) return;
  2534. const shortId = currentRequestId.substring(0, 8);
  2535. debugLog(`[取消] [${shortId}] 取消录音`);
  2536. recorderRef.current?.stop();
  2537. resetFrequencyVisualization();
  2538. stopRecordingTimer();
  2539. // 清理解码中的情绪状态与 GIF 切换定时器
  2540. pendingEmotionRef.current = null;
  2541. if (emotionGifTimerRef.current) {
  2542. clearTimeout(emotionGifTimerRef.current);
  2543. emotionGifTimerRef.current = null;
  2544. }
  2545. // 恢复默认头像 GIF
  2546. showDefaultGif();
  2547. wsRef.current?.sendCancel(currentRequestId);
  2548. cancelledRequestIdsRef.current.add(currentRequestId);
  2549. performanceLogger.clearRequest(currentRequestId);
  2550. reset();
  2551. };
  2552. const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
  2553. const energyStoneAssets: Record<'mission' | 'conversation', string> = {
  2554. mission: missionEnergyStone,
  2555. conversation: conversationEnergyStone,
  2556. };
  2557. const energyStoneHintAssets: Record<'mission' | 'conversation', string> = {
  2558. mission: missionEnergyStoneHint,
  2559. conversation: conversationEnergyStoneHint,
  2560. };
  2561. const handleEnergyStoneSpawn = useCallback(
  2562. (payload: EnergyStoneSpawnMessage) => {
  2563. // 展示能量石
  2564. spawnStone(payload);
  2565. // 如果是对话能量石,刷新一次顶部的数量展示
  2566. if (payload.stoneType === 'conversation') {
  2567. const uid = getUserId();
  2568. if (uid) {
  2569. fetchConversationStoneCount(uid);
  2570. }
  2571. }
  2572. },
  2573. [spawnStone, fetchConversationStoneCount]
  2574. );
  2575. // 根据背景图片获取对应的树木装饰图片URL(默认背景下录音时切到 bg-chatting.png,发送/取消后恢复)
  2576. const getTreesImage = (): string | null => {
  2577. const bgId = getCurrentBackgroundImageId();
  2578. const isDefaultBg = !bgId || bgId === 'default';
  2579. if (isDefaultBg && currentState === 'recording') {
  2580. return bgChattingUrl;
  2581. }
  2582. // 实时从 localStorage 读取最新的背景图片URL,使用背景图片作为树木装饰
  2583. const currentBgUrl = getCurrentBackgroundImageUrl() || backgroundImageUrl;
  2584. if (currentBgUrl && currentBgUrl.includes('init.svg')) {
  2585. // 如果是旧的 init.svg,强制替换为 init1.svg
  2586. return getOssUrl('init1.svg');
  2587. }
  2588. return currentBgUrl;
  2589. };
  2590. // 根据背景图片ID获取树木装饰图片的样式配置
  2591. const getTreesImageStyle = (): React.CSSProperties => {
  2592. const baseStyle: React.CSSProperties = {
  2593. width: '100%',
  2594. height: '100%',
  2595. top: 0,
  2596. left: 0,
  2597. objectFit: 'cover',
  2598. zIndex: 1,
  2599. };
  2600. // 从 localStorage 读取透明度配置(从数据库metadata中获取)
  2601. const opacity = getCurrentBackgroundImageOpacity();
  2602. return { ...baseStyle, opacity };
  2603. };
  2604. const defaultGifUrl = avatarGifSet?.default ?? '';
  2605. // 双 img 固定角色:default 层永远 default;对话层 playing 时播 emotion/talking
  2606. // 当对话层切换到下面(idle)时,直接把对话层 src 设为 default,下次对话无需再关心其是否已恢复默认
  2607. // 仅在发送/播放时把动画/对话层置顶,录音期间保持 default 层在上,多留时间消化上一轮残留
  2608. const showConversationOnTop = currentState === 'sending' || currentState === 'playing';
  2609. const conversationLayerSrc = currentState === 'playing' ? (currentAvatarGif || defaultGifUrl) : defaultGifUrl;
  2610. return (
  2611. <div
  2612. style={{
  2613. position: 'relative',
  2614. width: '100%',
  2615. minHeight: '100dvh',
  2616. }}
  2617. >
  2618. {/* 树木装饰 - 根据背景图片调整图片和样式 */}
  2619. {getTreesImage() && (
  2620. <img
  2621. src={getTreesImage() || undefined}
  2622. alt={tCommon('a11y.trees')}
  2623. className="chatbot-trees absolute pointer-events-none"
  2624. style={getTreesImageStyle()}
  2625. />
  2626. )}
  2627. {/* 隐藏的能量石容器 - 用于 useEnergyStones hook 计算位置 */}
  2628. <div
  2629. ref={hiddenEnergyStoneZoneRef}
  2630. style={{
  2631. position: 'absolute',
  2632. width: px2vmin(343), // 设计稿 343px,基于 vw/vh 最小值
  2633. height: px2vmin(172), // 设计稿 172px,基于 vw/vh 最小值
  2634. top: px2vmin(126), // 设计稿 top 126px,基于 vw/vh 最小值
  2635. left: '50%',
  2636. transform: 'translateX(-50%)',
  2637. visibility: 'hidden',
  2638. pointerEvents: 'none',
  2639. zIndex: -1,
  2640. }}
  2641. />
  2642. {/* 亲密度 + 对话能量石状态(V2 设计稿 393×852,基于 min(vw, vh)) */}
  2643. <div
  2644. style={{
  2645. position: 'absolute',
  2646. top: px2vmin(115, 'v2'),
  2647. left: px2vmin(24, 'v2'),
  2648. width: px2vmin(70, 'v2'),
  2649. height: px2vmin(54, 'v2'),
  2650. backgroundColor: 'rgba(0, 0, 0, 0.4)',
  2651. borderRadius: px2vmin(8, 'v2'),
  2652. boxSizing: 'border-box',
  2653. zIndex: 6,
  2654. }}
  2655. >
  2656. {/* 亲密度心形展示:顶部 6,宽 58,高 14,左右居中 */}
  2657. <div
  2658. style={{
  2659. position: 'absolute',
  2660. top: px2vmin(6, 'v2'),
  2661. left: '50%',
  2662. transform: 'translateX(-50%)',
  2663. width: px2vmin(58, 'v2'),
  2664. height: px2vmin(14, 'v2'),
  2665. display: 'flex',
  2666. alignItems: 'center',
  2667. justifyContent: 'space-between',
  2668. }}
  2669. >
  2670. {Array.from({ length: 3 }, (_, index) => {
  2671. const isFilled = index < Math.max(0, Math.min(3, intimacyHeartCount));
  2672. return (
  2673. <img
  2674. key={index}
  2675. src={isFilled ? heartFilled : heartBroken}
  2676. alt={isFilled ? 'heart' : 'heart-broken'}
  2677. style={{
  2678. width: px2vmin(14, 'v2'),
  2679. height: px2vmin(14, 'v2'),
  2680. objectFit: 'contain',
  2681. }}
  2682. />
  2683. );
  2684. })}
  2685. </div>
  2686. {/* 对话能量石数量:顶部 24,宽 55,左右居中,点击弹出对话能量石说明弹窗 */}
  2687. <div
  2688. role="button"
  2689. tabIndex={0}
  2690. onClick={() => {
  2691. setEnergyStoneModalType('conversation');
  2692. setShowMissionStoneModal(true);
  2693. }}
  2694. onKeyDown={(e) => {
  2695. if (e.key === 'Enter' || e.key === ' ') {
  2696. e.preventDefault();
  2697. setEnergyStoneModalType('conversation');
  2698. setShowMissionStoneModal(true);
  2699. }
  2700. }}
  2701. style={{
  2702. position: 'absolute',
  2703. top: px2vmin(24, 'v2'),
  2704. left: '50%',
  2705. transform: 'translateX(-50%)',
  2706. width: px2vmin(55, 'v2'),
  2707. cursor: 'pointer',
  2708. }}
  2709. >
  2710. <img
  2711. src={conversationEnergyStoneCountBg}
  2712. alt={tProfile('chatStoneTitle')}
  2713. style={{
  2714. width: '100%',
  2715. height: '100%',
  2716. objectFit: 'contain',
  2717. display: 'block',
  2718. }}
  2719. />
  2720. <span
  2721. style={{
  2722. position: 'absolute',
  2723. top: '50%',
  2724. left: '70%',
  2725. transform: 'translate(-50%, -50%)',
  2726. fontSize: px2vmin(10, 'v2'),
  2727. color: '#000',
  2728. fontWeight: 500,
  2729. whiteSpace: 'nowrap',
  2730. }}
  2731. >
  2732. {conversationStoneCount > 999 ? '999+' : conversationStoneCount}
  2733. </span>
  2734. </div>
  2735. </div>
  2736. {/* 能量石展示区域 - 已废弃,能量石现在独立定位,不再嵌套在此容器中 */}
  2737. {/* <div
  2738. className="chatbot-energy-zone absolute"
  2739. style={{
  2740. width: px2vmin(343), // 设计稿 343px,基于 vw/vh 最小值
  2741. height: px2vmin(172), // 设计稿 172px,基于 vw/vh 最小值
  2742. top: px2vmin(126), // 设计稿 top 126px,基于 vw/vh 最小值
  2743. left: '50%',
  2744. transform: 'translateX(-50%)',
  2745. zIndex: 4,
  2746. pointerEvents: 'none',
  2747. }}
  2748. >
  2749. <div className="energy-stone-layer" />
  2750. </div> */}
  2751. {/* 正式能量石 - 仅渲染对话能量石,不渲染任务能量石 */}
  2752. {energyStones
  2753. .filter((stone) => stone.stoneType === 'conversation')
  2754. .map((stone) => {
  2755. const asset = energyStoneAssets[stone.stoneType];
  2756. const hintAsset = energyStoneHintAssets[stone.stoneType];
  2757. const stoneSize = 48; // 设计稿 48px,基于 vw/vh 最小值
  2758. const hintSize = 92; // 设计稿 92px,基于 vw/vh 最小值
  2759. const shouldShowHint = stone.stoneType === 'mission'
  2760. ? !hasClickedMissionStone
  2761. : !hasClickedConversationStone;
  2762. // 计算绝对位置:基于能量石区域的边界
  2763. // 区域:left 50% translateX(-50%), top: px2vmin(126), width: px2vmin(343), height: px2vmin(172)
  2764. // 能量石在区域内的百分比位置转换为绝对位置
  2765. // left: 50% + (xRatio - 0.5) * zoneWidth
  2766. // top: zoneTop + yRatio * zoneHeight
  2767. const zoneTop = px2vmin(126);
  2768. const zoneWidth = px2vmin(343);
  2769. const zoneHeight = px2vmin(172);
  2770. const absoluteLeft = `calc(50% + (${stone.position.xRatio} - 0.5) * ${zoneWidth})`;
  2771. const absoluteTop = `calc(${zoneTop} + ${stone.position.yRatio} * ${zoneHeight})`;
  2772. return (
  2773. <div
  2774. key={stone.stoneId}
  2775. data-stone-id={stone.stoneId}
  2776. data-fading={stone.fading ? 'true' : 'false'}
  2777. className={`energy-stone-container ${stone.stoneType === 'mission' ? 'energy-stone-mission' : ''}`}
  2778. style={{
  2779. position: 'absolute',
  2780. left: absoluteLeft,
  2781. top: absoluteTop,
  2782. transform: 'translate(-50%, -50%)',
  2783. width: px2vmin(stoneSize),
  2784. height: px2vmin(stoneSize),
  2785. pointerEvents: 'auto',
  2786. cursor: 'pointer',
  2787. zIndex: 5,
  2788. }}
  2789. >
  2790. {/* 光晕层 - 只在未点击时显示,但在动画过程中也显示以跟随能量石 */}
  2791. {shouldShowHint && (
  2792. <img
  2793. src={hintAsset}
  2794. alt=""
  2795. className="energy-stone-hint"
  2796. style={{
  2797. position: 'absolute',
  2798. top: '50%',
  2799. left: '50%',
  2800. transform: 'translate(-50%, -50%)',
  2801. transformOrigin: 'center center',
  2802. width: px2vmin(hintSize),
  2803. height: px2vmin(hintSize),
  2804. pointerEvents: 'none',
  2805. zIndex: 0,
  2806. opacity: stone.fading ? 0 : undefined, // 动画过程中逐渐消失
  2807. maxWidth: 'none',
  2808. maxHeight: 'none',
  2809. objectFit: 'contain',
  2810. }}
  2811. />
  2812. )}
  2813. {/* 能量石按钮 */}
  2814. <button
  2815. type="button"
  2816. className="energy-stone"
  2817. data-type={stone.stoneType}
  2818. data-fading={stone.fading ? 'true' : 'false'}
  2819. style={{
  2820. position: 'absolute',
  2821. top: '50%',
  2822. left: '50%',
  2823. transform: 'translate(-50%, -50%)',
  2824. transformOrigin: 'center center',
  2825. width: px2vmin(stoneSize),
  2826. height: px2vmin(stoneSize),
  2827. zIndex: stone.fading ? 100 : 1,
  2828. pointerEvents: stone.fading ? 'none' : 'auto',
  2829. }}
  2830. onClick={async () => {
  2831. // 更新点击状态(控制光晕显示)
  2832. const clickedStorageKey = stone.stoneType === 'mission'
  2833. ? STORAGE_KEYS.HAS_CLICKED_MISSION_STONE
  2834. : STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE;
  2835. const hasClicked = getStorage(clickedStorageKey);
  2836. const isFirstClick = hasClicked !== 'true';
  2837. // 检查是否是第一次点击,决定是否显示弹窗
  2838. const storageKey = stone.stoneType === 'mission'
  2839. ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL
  2840. : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
  2841. const hasShown = getStorage(storageKey);
  2842. const isFirstShowModal = hasShown !== 'true';
  2843. // 更新点击状态(控制光晕显示)
  2844. if (isFirstClick) {
  2845. setStorage(clickedStorageKey, 'true');
  2846. if (stone.stoneType === 'mission') {
  2847. setHasClickedMissionStone(true);
  2848. } else {
  2849. setHasClickedConversationStone(true);
  2850. }
  2851. }
  2852. // 准备需要同步到数据库的字段
  2853. const userId = getUserId();
  2854. const updatePayload: Record<string, boolean> = {};
  2855. // 如果是第一次点击,需要更新点击状态
  2856. if (isFirstClick) {
  2857. if (stone.stoneType === 'mission') {
  2858. updatePayload.has_clicked_mission_stone = true;
  2859. } else {
  2860. updatePayload.has_clicked_conversation_stone = true;
  2861. }
  2862. }
  2863. // 如果是第一次显示弹窗,需要更新弹窗状态
  2864. if (isFirstShowModal) {
  2865. setEnergyStoneModalType(stone.stoneType);
  2866. setShowMissionStoneModal(true);
  2867. pendingStoneIdRef.current = stone.stoneId;
  2868. if (stone.stoneType === 'mission') {
  2869. updatePayload.has_shown_mission_stone_modal = true;
  2870. } else {
  2871. updatePayload.has_shown_conversation_stone_modal = true;
  2872. }
  2873. }
  2874. // 非首次弹窗,直接播放动画并移除能量石
  2875. if (!isFirstShowModal) {
  2876. startStoneAnimationAndRemove(stone.stoneId);
  2877. }
  2878. // 合并同步到数据库(一次性更新所有需要更新的字段)
  2879. if (userId && Object.keys(updatePayload).length > 0) {
  2880. try {
  2881. await updateUserUIState(userId, updatePayload);
  2882. debugLog('[能量石] 能量石状态已同步到数据库:', updatePayload);
  2883. // 同步成功后更新 localStorage
  2884. if (isFirstShowModal) {
  2885. setStorage(storageKey, 'true');
  2886. }
  2887. } catch (error) {
  2888. console.error('同步能量石状态到数据库失败:', error);
  2889. // 同步失败时,如果只是点击状态失败,不影响弹窗显示
  2890. // 但如果是弹窗状态失败,需要回滚弹窗显示
  2891. if (isFirstShowModal) {
  2892. // 弹窗已经显示,但同步失败,记录错误以便后续补偿
  2893. console.warn('[能量石] 弹窗状态同步失败,但弹窗已显示');
  2894. }
  2895. }
  2896. } else if (!userId) {
  2897. // 没有 userId(游客态),仅更新本地
  2898. if (isFirstShowModal) {
  2899. setStorage(storageKey, 'true');
  2900. }
  2901. debugLog('[能量石] 游客状态,仅更新本地,等待后续补偿同步');
  2902. } else {
  2903. // 没有需要更新的字段,直接更新 localStorage(如果显示弹窗)
  2904. if (isFirstShowModal) {
  2905. setStorage(storageKey, 'true');
  2906. }
  2907. }
  2908. }}
  2909. aria-label={stone.stoneType === 'mission' ? tProfile('missionStoneTitle') : tProfile('chatStoneTitle')}
  2910. >
  2911. <img src={asset} alt="" aria-hidden="true" />
  2912. </button>
  2913. </div>
  2914. );
  2915. })}
  2916. {/* AnimaZone 动画形象 - 双 img 固定角色:default 层永远 default;对话层 emotion→talking,放下时静默换 default */}
  2917. <div
  2918. className="chatbot-anima-zone-avatar absolute"
  2919. style={{
  2920. width: px2vmin(238),
  2921. height: px2vmin(238),
  2922. top: px2vmin(292),
  2923. left: '50%',
  2924. transform: 'translateX(-50%)',
  2925. opacity: 1,
  2926. zIndex: 2,
  2927. }}
  2928. >
  2929. <img
  2930. src={defaultGifUrl || undefined}
  2931. alt={currentAvatarAlt}
  2932. loading="eager"
  2933. decoding="async"
  2934. style={{
  2935. position: 'absolute',
  2936. inset: 0,
  2937. width: '100%',
  2938. height: '100%',
  2939. objectFit: 'contain',
  2940. pointerEvents: 'none',
  2941. zIndex: showConversationOnTop ? 0 : 1,
  2942. opacity: showConversationOnTop ? 0 : 1,
  2943. }}
  2944. />
  2945. <img
  2946. src={conversationLayerSrc || undefined}
  2947. alt={currentAvatarAlt}
  2948. loading="eager"
  2949. decoding="async"
  2950. style={{
  2951. position: 'absolute',
  2952. inset: 0,
  2953. width: '100%',
  2954. height: '100%',
  2955. objectFit: 'contain',
  2956. pointerEvents: 'none',
  2957. zIndex: showConversationOnTop ? 1 : 0,
  2958. opacity: showConversationOnTop ? 1 : 0,
  2959. }}
  2960. />
  2961. </div>
  2962. {/* 字幕区域 - 设计稿 393×852:top 500,高 92,宽 312,左右居中 */}
  2963. {subtitleOn && subtitleVisible && recognizedText && currentState === 'playing' && (
  2964. <div
  2965. ref={subtitleContainerRef}
  2966. className="chatbot-subtitle-container"
  2967. style={{
  2968. position: 'absolute',
  2969. // 设计稿 393×852:top 500px → 使用 px2vmin 按 min(vw, vh) 适配
  2970. top: px2vmin(530, 'v2'),
  2971. left: '50%',
  2972. transform: 'translateX(-50%)',
  2973. // 设计稿宽度 312px、高度 92px
  2974. width: px2vmin(312, 'v2'),
  2975. height: px2vmin(72, 'v2'),
  2976. borderRadius: px2vmin(16),
  2977. backgroundColor: 'rgba(0, 0, 0, 0.4)',
  2978. color: '#F5F3F0',
  2979. fontSize: px2vmin(14),
  2980. lineHeight: px2vmin(20),
  2981. padding: px2vmin(12),
  2982. boxSizing: 'border-box',
  2983. // 方案 A:完整文本 + 可滚动,但通过样式隐藏滚动条
  2984. overflowY: 'auto',
  2985. WebkitOverflowScrolling: 'touch',
  2986. zIndex: 20,
  2987. }}
  2988. >
  2989. {recognizedText}
  2990. </div>
  2991. )}
  2992. {/* Yp 图片 - 设计稿 393×852,相对动画形象顶部 191,64×14 */}
  2993. <div
  2994. className="absolute"
  2995. style={{
  2996. top: `calc(${px2vmin(292)} + ${px2vmin(191)})`,
  2997. left: '50%',
  2998. transform: 'translateX(-50%)',
  2999. width: px2vmin(64),
  3000. height: px2vmin(14),
  3001. zIndex: 10,
  3002. }}
  3003. >
  3004. <img
  3005. src={YpImage}
  3006. alt={tCommon('a11y.yp')}
  3007. className="w-full h-full object-contain"
  3008. style={{ width: '100%', height: '100%' }}
  3009. draggable={false}
  3010. />
  3011. </div>
  3012. {/* 录音时按钮区域上方:0~50 秒显示 audio-wave.gif,50~60 秒显示 00:50…01:00 后自动发送 */}
  3013. {currentState === 'recording' && (
  3014. <div
  3015. className="absolute left-1/2 -translate-x-1/2 flex items-center justify-center pointer-events-none"
  3016. style={{
  3017. width: px2vmin(64),
  3018. height: px2vmin(32),
  3019. top: px2vmin(592 - 32 - 8),
  3020. zIndex: 19,
  3021. backgroundColor: 'rgba(0, 0, 0, 0.4)',
  3022. borderRadius: 20,
  3023. }}
  3024. >
  3025. {remainingTimeMs !== null && remainingTimeMs <= COUNTDOWN_THRESHOLD_MS ? (
  3026. <span
  3027. style={{
  3028. fontSize: px2vmin(14),
  3029. color: 'rgba(245, 243, 240, 1)',
  3030. fontWeight: 600,
  3031. }}
  3032. >
  3033. {formatElapsed50to60(remainingTimeMs)}
  3034. </span>
  3035. ) : (
  3036. <img
  3037. src={audioWaveGif}
  3038. alt=""
  3039. className="w-full h-full object-contain"
  3040. draggable={false}
  3041. />
  3042. )}
  3043. </div>
  3044. )}
  3045. {/* 按钮区域 - 设计稿 393×852:top 592,高 98,宽 312,左右居中 */}
  3046. <div
  3047. className="chatbot-progress-container absolute"
  3048. style={{
  3049. width: px2vmin(312),
  3050. height: px2vmin(98),
  3051. top: px2vmin(592),
  3052. left: '50%',
  3053. transform: 'translateX(-50%)',
  3054. zIndex: 20,
  3055. display: 'flex',
  3056. alignItems: 'center',
  3057. justifyContent: 'space-between',
  3058. }}
  3059. >
  3060. {/* 左侧 - 非录音:功能菜单按钮 48×47;录音:取消按钮(以区域中心为界,距中心 16.67) */}
  3061. <div
  3062. className="absolute"
  3063. style={{
  3064. left: 0,
  3065. width: currentState === 'recording' ? '50%' : undefined,
  3066. top: '50%',
  3067. transform: 'translateY(-50%)',
  3068. height: px2vmin(98),
  3069. display: 'flex',
  3070. alignItems: 'center',
  3071. justifyContent: currentState === 'recording' ? 'flex-end' : 'center',
  3072. paddingRight: currentState === 'recording' ? px2vmin(16.67) : 0,
  3073. position: 'relative',
  3074. }}
  3075. >
  3076. {currentState !== 'recording' && (
  3077. <>
  3078. <button
  3079. type="button"
  3080. className="touch-manipulation cursor-pointer border-0 bg-transparent p-0"
  3081. onClick={() => setShowFuncMenuPopup((prev) => !prev)}
  3082. style={{
  3083. width: px2vmin(48),
  3084. height: px2vmin(47),
  3085. display: 'flex',
  3086. alignItems: 'center',
  3087. justifyContent: 'center',
  3088. }}
  3089. aria-label={tCommon('a11y.menu')}
  3090. >
  3091. <img
  3092. src={settingIcon}
  3093. alt={tCommon('a11y.menu')}
  3094. className="pointer-events-none w-full h-full object-contain"
  3095. draggable={false}
  3096. />
  3097. </button>
  3098. {showFuncMenuPopup && (
  3099. <>
  3100. <div
  3101. className="fixed inset-0 z-30"
  3102. style={{ left: 0, right: 0, top: 0, bottom: 0 }}
  3103. aria-hidden
  3104. onClick={() => setShowFuncMenuPopup(false)}
  3105. />
  3106. <div
  3107. className="absolute z-40 flex flex-col rounded-lg"
  3108. style={{
  3109. left: 0,
  3110. bottom: '100%',
  3111. marginBottom: px2vmin(2, 'v2'),
  3112. width: px2vmin(126, 'v2'),
  3113. height: px2vmin(166, 'v2'),
  3114. backgroundColor: 'rgba(0, 0, 0, 0.4)',
  3115. padding: px2vmin(8, 'v2'),
  3116. display: 'grid',
  3117. gridTemplateColumns: '1fr 1fr',
  3118. gridTemplateRows: '1fr 1fr',
  3119. gap: px2vmin(8, 'v2'),
  3120. boxSizing: 'border-box',
  3121. }}
  3122. >
  3123. {[
  3124. {
  3125. key: 'profile',
  3126. label: tCommon('profile'),
  3127. icon: profileMenuIcon,
  3128. enabled: true,
  3129. onClick: () => {
  3130. setShowFuncMenuPopup(false);
  3131. navigate('/profile');
  3132. },
  3133. },
  3134. {
  3135. key: 'shop',
  3136. label: tCommon('shop'),
  3137. icon: shopMenuIcon,
  3138. enabled: false,
  3139. },
  3140. {
  3141. key: 'world',
  3142. label: tCommon('world'),
  3143. icon: worldMenuIcon,
  3144. enabled: false,
  3145. },
  3146. {
  3147. key: 'journal',
  3148. label: tCommon('journal'),
  3149. icon: diaryMenuIcon,
  3150. enabled: false,
  3151. },
  3152. ].map((item) => (
  3153. <button
  3154. key={item.key}
  3155. type="button"
  3156. className="touch-manipulation border-0 bg-transparent p-0 flex flex-col items-center justify-center gap-0"
  3157. disabled={!item.enabled}
  3158. onClick={item.enabled ? item.onClick : undefined}
  3159. style={{
  3160. cursor: item.enabled ? 'pointer' : 'default',
  3161. opacity: item.enabled ? 1 : 1,
  3162. }}
  3163. >
  3164. <div
  3165. style={{
  3166. position: 'relative',
  3167. width: px2vmin(48, 'v2'),
  3168. height: px2vmin(48, 'v2'),
  3169. border: 'none',
  3170. flexShrink: 0,
  3171. }}
  3172. >
  3173. <img
  3174. src={item.icon}
  3175. alt=""
  3176. className="w-full h-full object-cover"
  3177. draggable={false}
  3178. style={{
  3179. width: px2vmin(48, 'v2'),
  3180. height: px2vmin(48, 'v2'),
  3181. borderRadius: px2vmin(20, 'v2'),
  3182. }}
  3183. />
  3184. {!item.enabled && (
  3185. <img
  3186. src={funcLockIcon}
  3187. alt=""
  3188. className="absolute pointer-events-none"
  3189. draggable={false}
  3190. style={{
  3191. width: px2vmin(12, 'v2'),
  3192. height: px2vmin(12, 'v2'),
  3193. top: 0,
  3194. right: 0,
  3195. zIndex: 1,
  3196. }}
  3197. />
  3198. )}
  3199. </div>
  3200. <span
  3201. style={{
  3202. fontSize: 12,
  3203. lineHeight: '16px',
  3204. color: '#F5F3F0',
  3205. opacity: item.enabled ? 1 : 0.6,
  3206. marginTop: px2vmin(4, 'v2'),
  3207. }}
  3208. >
  3209. {item.label}
  3210. </span>
  3211. </button>
  3212. ))}
  3213. </div>
  3214. </>
  3215. )}
  3216. </>
  3217. )}
  3218. {currentState === 'recording' && (
  3219. <div
  3220. className="chatbot-action-buttons flex items-center justify-center cursor-pointer touch-manipulation relative"
  3221. onClick={handleCancel}
  3222. style={{
  3223. width: px2vmin(56),
  3224. height: px2vmin(54),
  3225. userSelect: 'none',
  3226. overflow: 'visible',
  3227. }}
  3228. >
  3229. <img src={cancelIcon} alt={tCommon('a11y.cancel')} className="pointer-events-none w-full h-full object-contain" draggable={false} />
  3230. {ENABLE_CHATBOT_TIPS && showCancelTooltip && (
  3231. <img
  3232. src={tooltipsCancel}
  3233. alt={tCommon('a11y.cancelTooltip')}
  3234. className="chatbot-tooltips absolute pointer-events-none"
  3235. style={{
  3236. width: px2vmin(109),
  3237. height: px2vmin(91),
  3238. bottom: '110%',
  3239. left: '-150%',
  3240. zIndex: 10,
  3241. }}
  3242. />
  3243. )}
  3244. </div>
  3245. )}
  3246. </div>
  3247. {/* 中间 - 非录音:录音开始按钮 84×82;录音:倒计时(最后10秒) */}
  3248. <div
  3249. className="absolute flex items-center justify-center"
  3250. style={{
  3251. left: '50%',
  3252. top: '50%',
  3253. transform: 'translate(-50%, -50%)',
  3254. }}
  3255. >
  3256. {currentState !== 'recording' && (
  3257. <div
  3258. className={`chatbot-record-button touch-manipulation flex items-center justify-center ${isConnected ? 'cursor-pointer' : 'cursor-not-allowed'}`}
  3259. onClick={handleStartRecord}
  3260. style={{
  3261. width: px2vmin(84),
  3262. height: px2vmin(82),
  3263. opacity: isConnected ? 1 : 0.55,
  3264. userSelect: 'none',
  3265. overflow: 'visible',
  3266. position: 'relative',
  3267. }}
  3268. role="button"
  3269. tabIndex={-1}
  3270. aria-disabled={!isConnected}
  3271. >
  3272. <img
  3273. src={isConnected ? luyinDefault : luyinDisable}
  3274. alt={tCommon('a11y.record')}
  3275. className="pointer-events-none w-full h-full object-contain"
  3276. draggable={false}
  3277. />
  3278. {ENABLE_CHATBOT_TIPS && showTapToChat && isConnected && (
  3279. <img
  3280. src={tapToChat}
  3281. alt={tCommon('a11y.tapToChat')}
  3282. className="chatbot-tooltips absolute pointer-events-none"
  3283. style={{
  3284. width: px2vmin(128),
  3285. height: px2vmin(100),
  3286. top: '-100%',
  3287. right: '-100%',
  3288. zIndex: 10,
  3289. }}
  3290. />
  3291. )}
  3292. </div>
  3293. )}
  3294. </div>
  3295. {/* 右侧 - 非录音:字幕开关 48×47;录音:发送按钮(以区域中心为界,距中心 16.67) */}
  3296. <div
  3297. className="absolute"
  3298. style={{
  3299. left: currentState === 'recording' ? '50%' : undefined,
  3300. right: 0,
  3301. width: currentState === 'recording' ? '50%' : undefined,
  3302. top: '50%',
  3303. transform: 'translateY(-50%)',
  3304. height: px2vmin(98),
  3305. display: 'flex',
  3306. alignItems: 'center',
  3307. justifyContent: currentState === 'recording' ? 'flex-start' : 'center',
  3308. paddingLeft: currentState === 'recording' ? px2vmin(16.67) : 0,
  3309. }}
  3310. >
  3311. {currentState !== 'recording' && (
  3312. <button
  3313. type="button"
  3314. className="touch-manipulation cursor-pointer border-0 bg-transparent p-0 flex items-center justify-center"
  3315. onClick={() => {
  3316. setSubtitleOn((prev) => {
  3317. const next = !prev;
  3318. // 持久化到 localStorage
  3319. setStorage(STORAGE_KEYS.SUBTITLE_ON, next ? 'true' : 'false');
  3320. // 同步到后端用户 UI 状态
  3321. const uid = getUserId();
  3322. if (uid) {
  3323. updateUserUIState(uid, { subtitle_on: next }).catch(() => {
  3324. // 同步失败不影响前端展示
  3325. });
  3326. }
  3327. return next;
  3328. });
  3329. }}
  3330. style={{
  3331. width: px2vmin(48),
  3332. height: px2vmin(47),
  3333. }}
  3334. aria-label={subtitleOn ? 'Close subtitle' : 'Open subtitle'}
  3335. >
  3336. <img
  3337. src={subtitleOn ? ccOpenIcon : ccCloseIcon}
  3338. alt={subtitleOn ? 'Subtitle on' : 'Subtitle off'}
  3339. className="pointer-events-none w-full h-full object-contain"
  3340. draggable={false}
  3341. />
  3342. </button>
  3343. )}
  3344. {currentState === 'recording' && (
  3345. <div
  3346. className="chatbot-action-buttons flex items-center justify-center cursor-pointer touch-manipulation relative"
  3347. onClick={handleSend}
  3348. style={{
  3349. width: px2vmin(56),
  3350. height: px2vmin(54),
  3351. userSelect: 'none',
  3352. overflow: 'visible',
  3353. }}
  3354. >
  3355. <img src={sendIcon} alt={tCommon('a11y.send')} className="pointer-events-none w-full h-full object-contain" draggable={false} />
  3356. {ENABLE_CHATBOT_TIPS && showSendTooltip && (
  3357. <img
  3358. src={tooltipsSend}
  3359. alt={tCommon('a11y.sendTooltip')}
  3360. className="chatbot-tooltips absolute pointer-events-none"
  3361. style={{
  3362. width: px2vmin(111),
  3363. height: 'auto',
  3364. bottom: '110%',
  3365. right: '-150%',
  3366. zIndex: 10,
  3367. }}
  3368. />
  3369. )}
  3370. </div>
  3371. )}
  3372. </div>
  3373. </div>
  3374. {/* 能量石弹窗 */}
  3375. <MissionEnergyStoneModal
  3376. visible={showMissionStoneModal}
  3377. onClose={handleMissionStoneModalClose}
  3378. stoneType={energyStoneModalType}
  3379. />
  3380. {/* 亲密度弹窗:不满 3 颗心时展示,点击「点亮」增加一颗并关闭 */}
  3381. <IntimacyModal
  3382. visible={showIntimacyModal}
  3383. heartCount={intimacyHeartCount}
  3384. daysSinceLastVisit={intimacyDaysSinceLastVisit}
  3385. canLightUpToday={canLightUpToday}
  3386. loading={intimacyLoading}
  3387. onClose={() => {
  3388. setShowIntimacyModal(false);
  3389. // 弹窗关闭后,如果尚未触发 greeting 且用户也未开始录音,则由前端显式发起首句
  3390. startGreeting();
  3391. }}
  3392. onLightUp={() => {
  3393. const uid = getUserId();
  3394. if (!uid) return;
  3395. setIntimacyLoading(true);
  3396. lightUpIntimacy(uid)
  3397. .then((res) => {
  3398. if (res?.success && res.data) {
  3399. setIntimacyHeartCount(res.data.heart_count);
  3400. setShowIntimacyModal(false);
  3401. // 点亮成功并关闭弹窗后,同样按需触发首句 greeting
  3402. startGreeting();
  3403. }
  3404. })
  3405. .finally(() => setIntimacyLoading(false));
  3406. }}
  3407. />
  3408. </div>
  3409. );
  3410. };