index.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { Image, Text, View, type CommonEvent } from "@tarojs/components";
  2. import {
  3. forwardRef,
  4. useEffect,
  5. useImperativeHandle,
  6. useState,
  7. useMemo,
  8. useCallback,
  9. memo,
  10. } from "react";
  11. import { useVoiceRecord } from "@/components/voice-recorder";
  12. import IconShare from "@/images/icon-share.png";
  13. import { chat } from "@/service/character";
  14. import { useCharacterAIStore } from "@/store/characterAIStore";
  15. import {
  16. greeting,
  17. } from '@/service/character'
  18. import type { ICharacter } from "@/types";
  19. import { ECharacterAISTATUS } from "@/types/index";
  20. import { useAudioPlayer } from "@/utils/audio";
  21. import { useBase64AudioPlayer } from "@/utils/audioBase64";
  22. import { handleShare } from "@/utils/share";
  23. import Taro, { useDidHide, useUnload } from "@tarojs/taro";
  24. import { axios } from "taro-axios";
  25. import style from "./index.module.less";
  26. import SharePopup from "@/components/custom-share/share-popup/index";
  27. import SpeakStatus from "./components/speak-status"
  28. import IconKeyboard from '@/components/icon/icon-keyboard';
  29. import { AvatarMedia } from "./components/AvatarMedia";
  30. import { setChatSession, getChatSession } from '@/store/chat'
  31. let cancelTokenSource = axios.CancelToken.source();
  32. interface Props {
  33. character: ICharacter;
  34. getSession?: boolean; // 是否发起greeting 请求获取聊天用的 session
  35. playGreeting?: boolean; // 是否播放欢迎语
  36. shareButton?: boolean; // 是否显示分享按钮
  37. avatarRenderer?: () => JSX.Element; // 自定义渲染头像
  38. nicknameRenderer?: () => JSX.Element; // 自定义渲染昵称
  39. enableGoPreview?: boolean; // 允许点击头像去预览
  40. buttonSize?: string; // 按钮大小
  41. }
  42. let source: AudioBufferSourceNode;
  43. export interface IChatAIComponent {
  44. stop: () => void;
  45. }
  46. const ChatAI = memo(forwardRef(
  47. (
  48. {
  49. character,
  50. playGreeting = false,
  51. getSession = false,
  52. enableGoPreview = false,
  53. shareButton = true,
  54. avatarRenderer,
  55. nicknameRenderer,
  56. buttonSize,
  57. }: Props,
  58. ref
  59. ) => {
  60. const currentCharacter = character;
  61. const [speechStatus, setSpeechStatus] = useState(0);
  62. const { start, stop, onVolumeChange, onStop } = useVoiceRecord();
  63. const { stopPlayChunk, onPlayerStatusChanged } = useAudioPlayer();
  64. const { playAudio, stopAudio, onEnded } = useBase64AudioPlayer();
  65. const { setStatus } = useCharacterAIStore();
  66. const [sharePopupVisible, setSharePopupVisible] = useState(false);
  67. // console.log(
  68. // "currentCharacter:",
  69. // currentCharacter?.profileId,
  70. // character.profileId,
  71. // getSession
  72. // );
  73. const handleShareClick = () => {
  74. handleShare();
  75. Taro.hideTabBar();
  76. setSharePopupVisible(true);
  77. };
  78. const handleSharePopupClosed = () => {
  79. Taro.showTabBar();
  80. setSharePopupVisible(false);
  81. };
  82. const stopSpeek = () => {
  83. setSpeechStatus(0);
  84. stop();
  85. };
  86. onEnded(()=> {
  87. setStatus(ECharacterAISTATUS.IDLE)
  88. })
  89. onPlayerStatusChanged((status: ECharacterAISTATUS) => {
  90. setStatus(status)
  91. });
  92. const stopPlay = () => {
  93. source && source.stop();
  94. stopPlayChunk();
  95. };
  96. const stopPropagation = (e?: CommonEvent)=> {
  97. e?.stopPropagation();
  98. }
  99. const onLongPress = (e?: CommonEvent) => {
  100. e?.stopPropagation();
  101. setSpeechStatus(1);
  102. stopPlay();
  103. start();
  104. };
  105. const onTouchEnd = () => {
  106. stopSpeek();
  107. };
  108. const handleTouchStart = (e: CommonEvent) => {
  109. // fix 解决pc端长按呼出右键菜单
  110. e.preventDefault();
  111. // fix 解决移动端长按依旧穿透
  112. e.stopPropagation();
  113. };
  114. onVolumeChange((_v) => {});
  115. onStop((res) => {
  116. const session = getChatSession()
  117. console.log("record stop:", res, session);
  118. stopSpeek();
  119. if (!res) {
  120. return;
  121. }
  122. console.log(character.profileId, session);
  123. if (character?.profileId && session) {
  124. setStatus(ECharacterAISTATUS.THINKING);
  125. chat(
  126. character.profileId,
  127. session,
  128. res?.tempFilePath || res?.arrayBuffer
  129. );
  130. }
  131. });
  132. // 打招呼,聊天前都需要先招呼获取聊天所需的 session
  133. const showGreeting = async () => {
  134. // cancelTokenSource.cancel('Previous showGreeting request canceled')
  135. // 重新创建一个新的取消令牌源
  136. if (!getSession) {
  137. return;
  138. }
  139. cancelTokenSource = axios.CancelToken.source();
  140. const res = await greeting(currentCharacter.profileId, {
  141. cancelToken: cancelTokenSource.token,
  142. });
  143. if(res.code !== 0){
  144. return
  145. }
  146. if (res?.data?.audio && playGreeting) {
  147. setStatus(ECharacterAISTATUS.RESPONDING);
  148. const _source = await playAudio(res?.data?.audio);
  149. if (_source) {
  150. source = _source;
  151. }
  152. }
  153. if (res?.data?.session) {
  154. setChatSession(res.data?.session);
  155. }
  156. };
  157. const handleNav = () => {
  158. if (enableGoPreview) {
  159. Taro.navigateTo({
  160. url: "/pages/profile/index?profileId=" + character.profileId,
  161. });
  162. }
  163. };
  164. const gotoChat = () => {
  165. Taro.navigateTo({
  166. url: "/pages/chat/index?profileId=" + character.profileId,
  167. });
  168. };
  169. // 侦听卡片是否为当前卡片,是的话需要播放欢迎语拿session
  170. // 后期是否优化考虑保存住每个卡片的 session
  171. useEffect(() => {
  172. if (currentCharacter?.profileId === character.profileId) {
  173. showGreeting();
  174. }
  175. return () => {
  176. cancelTokenSource.cancel("Previous showGreeting request canceled");
  177. source && source.stop();
  178. };
  179. }, [currentCharacter?.profileId, character.profileId, getSession]);
  180. const stopChatAI = useCallback(() => {
  181. // todo: 调用此方法会导致组件重新渲染,需要优化
  182. setStatus(ECharacterAISTATUS.IDLE);
  183. stopPlay();
  184. },[]);
  185. // 对外暴露 stop 方法
  186. useImperativeHandle(ref, () => {
  187. return {
  188. stop: stopChatAI,
  189. };
  190. });
  191. useDidHide(() => {
  192. stopPlay();
  193. stopAudio();
  194. });
  195. useUnload(() => {
  196. stopPlay();
  197. stopAudio();
  198. });
  199. // 使用 useCallback 缓存渲染函数
  200. const renderSpeechButton = useMemo(() => {
  201. if (process.env.TARO_ENV == "h5") {
  202. return <></>;
  203. }
  204. const idle = speechStatus === 0;
  205. return (
  206. <View className={`${idle ? style.speechButton : style.speechButtonActive} ${buttonSize === 'small' ? style.buttonSmall : ''}`}>
  207. <View className={idle ? style.speechButtonInner : "hidden"}>
  208. <View className={style.keyboardButton} onLongPress={stopPropagation}
  209. onTouchStart={stopPropagation}
  210. onTouchEnd={stopPropagation}
  211. onClick={()=> {gotoChat()}}
  212. >
  213. <IconKeyboard></IconKeyboard>
  214. </View>
  215. <Text className={`font-medium ${style.speechText}`}>按住对话</Text>
  216. </View>
  217. </View>
  218. );
  219. }, [speechStatus]);
  220. const renderNickName = useMemo(() => {
  221. if (nicknameRenderer) {
  222. return nicknameRenderer();
  223. }
  224. return <View className="truncate">{character?.name}</View>;
  225. }, [character?.name, nicknameRenderer]);
  226. const renderNameAndAvatar = useMemo(() => {
  227. return (
  228. <>
  229. <View
  230. className={`flex items-center justify-center ${style.nickname} truncate`}
  231. onClick={handleNav}
  232. id="user-name"
  233. >
  234. {renderNickName}
  235. </View>
  236. <View className={style.circleOurter} onClick={handleNav}>
  237. <View className={style.circleInner}>
  238. {/* {renderAIStatus} */}
  239. <SpeakStatus></SpeakStatus>
  240. {avatarRenderer && avatarRenderer()}
  241. {character?.avatar && !avatarRenderer && (
  242. <AvatarMedia character={character}></AvatarMedia>
  243. )}
  244. </View>
  245. </View>
  246. </>
  247. );
  248. }, [character?.avatar, avatarRenderer, handleNav, renderNickName]);
  249. return (
  250. <View
  251. className={`flex flex-col items-center relative w-full ${style.wrapper}`}
  252. >
  253. <View
  254. className={`${style.cardContainer}`}
  255. style={character.customStyled}
  256. >
  257. {renderNameAndAvatar}
  258. <View
  259. className={`flex flex-col items-center justify-center pt-42 gap-12`}
  260. onLongPress={onLongPress}
  261. onTouchStart={handleTouchStart}
  262. onTouchEnd={onTouchEnd}
  263. >
  264. {renderSpeechButton}
  265. {shareButton && (
  266. <>
  267. <View className={style.shareButton} onClick={handleShareClick}>
  268. <Image src={IconShare} className="w-20 h-20" />
  269. {/* <Button openType={"share"} className="share-button"></Button> */}
  270. <Text>分享</Text>
  271. </View>
  272. <SharePopup
  273. character={character}
  274. visible={sharePopupVisible}
  275. onClose={handleSharePopupClosed}
  276. ></SharePopup>
  277. </>
  278. )}
  279. </View>
  280. </View>
  281. </View>
  282. );
  283. }
  284. ));
  285. // 添加显示名称,方便调试
  286. ChatAI.displayName = 'ChatAI';
  287. export default ChatAI;