index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { View, ScrollView } from "@tarojs/components";
  2. import NavBarNormal from "@/components/NavBarNormal/index";
  3. import PageCustom from "@/components/page-custom/index";
  4. import Taro, { useDidShow, useRouter, useUnload } from "@tarojs/taro";
  5. import ChatMessage from "@/components/chat-message";
  6. import InputBar from "./components/input-bar";
  7. import { useEffect, useState, useRef, useMemo } from "react";
  8. import { useTextChat } from "@/store/textChat";
  9. import { formatMessageTime } from "@/utils/timeUtils";
  10. import { formatMessageItem } from "@/utils/messageUtils";
  11. import {
  12. TRobotMessage,
  13. TMessage,
  14. TAnyMessage,
  15. } from "@/types/bot";
  16. import ChatGreeting from "./components/ChatGreeting";
  17. import IconArrowLeftWhite24 from "@/components/icon/IconArrowLeftWhite24";
  18. import IconArrowLeft from "@/components/icon/icon-arrow-left";
  19. import PersonalCard from "./components/personal-card";
  20. import ButtonEnableStreamVoice from "./components/OptionButtons/ButtonEnableStreamVoice";
  21. import { useAgentStore } from "@/store/agentStore";
  22. import { useLoadMoreInfinite, createKey } from "@/utils/loadMoreInfinite";
  23. import { getMessageHistories } from "@/service/chat";
  24. import RecommendQuestions from "./components/RecommendQuestions";
  25. import { useKeyboard } from "./components/keyboard";
  26. import { useAppStore } from "@/store/appStore";
  27. import { usePersistentState } from '@/hooks/usePersistentState';
  28. export default function Index() {
  29. const router = useRouter();
  30. const { agentId, isVisitor } = router.params;
  31. if (!agentId) {
  32. return <View>没有相应的智能体</View>;
  33. }
  34. const { fetchAgent, fetchAgentProfile } = useAgentStore();
  35. const bottomSafeHeight = useAppStore( state => state.bottomSafeHeight)
  36. const agent = useAgentStore((state) => {
  37. if (isVisitor === "true") {
  38. return state.agentProfile;
  39. }
  40. return state.agent;
  41. });
  42. const scrollViewRef = useRef<any>(null);
  43. const messageList = useTextChat((state) => state.list);
  44. const [inputContainerHeight,setInputContainerHeight]= useState(0)
  45. const [streamVoiceEnable, setStreamVoiceEnable]= usePersistentState('streamVoiceEnable', false)
  46. const { keyboardHeight, marginTopOffset, triggerHeightUpdate } = useKeyboard(
  47. scrollViewRef,
  48. "#messageList",
  49. "#scrollView"
  50. );
  51. // 输入框容器
  52. // 针对没有 safeArea?.bottom 的手机,需要额外增加 12 高度
  53. let inputContainerBottomOffset = 0
  54. if(bottomSafeHeight <= 0){
  55. inputContainerBottomOffset = 12
  56. }
  57. const { destroy, setScrollTop, genSessionId, setAutoScroll } = useTextChat();
  58. const scrollTop = useTextChat((state) => state.scrollTop);
  59. // 放在组件里
  60. const initialScrolledRef = useRef(false);
  61. const fetcher = async ([_url, { nextId, pageSize }]) => {
  62. const _nextId = nextId ? decodeURIComponent(nextId) : nextId;
  63. const res = await getMessageHistories({
  64. agentId,
  65. startId: _nextId,
  66. pageSize,
  67. });
  68. return res.data;
  69. };
  70. // 获取历史聊天记录
  71. const { list, loadMore, pageIndex, mutate } = useLoadMoreInfinite<
  72. TMessage[] | TRobotMessage[]
  73. >(createKey(`messeagehistories${isVisitor}${agentId}`), fetcher);
  74. // 解析消息体 content
  75. const parsedList = list.filter((item)=> !!item.content.length).map(formatMessageItem);
  76. // 1. 按 sessionId 分组,并记录每组的最早时间
  77. const resultMap = useMemo(() => {
  78. const allMessages = [...[...parsedList].reverse(), ...messageList];
  79. return allMessages.reduce((acc, item) => {
  80. const { sessionId, msgTime } = item;
  81. if (!sessionId || !msgTime) {
  82. return acc;
  83. }
  84. let _msgTime = msgTime.replace(/\-/g, "/");
  85. if (!acc[sessionId]) {
  86. acc[sessionId] = {
  87. dt: _msgTime, // 初始化当前组的最早时间
  88. list: [item], // 初始化当前组的记录列表
  89. };
  90. } else {
  91. // 更新最早时间(如果当前记录的 msgTime 更早)
  92. if (new Date(_msgTime) < new Date(acc[sessionId].dt)) {
  93. acc[sessionId].dt = msgTime;
  94. console.log('yoyoyo')
  95. }
  96. // 将记录添加到当前组
  97. acc[sessionId].list.push(item);
  98. }
  99. return acc;
  100. }, {}) as {
  101. dt: string;
  102. list: TAnyMessage[];
  103. }[];
  104. }, [parsedList, messageList]);
  105. // 2. 转换为最终数组格式
  106. const result = Object.values(resultMap);
  107. const messagesLength = useMemo(() => result.length, [result.length]);
  108. const prevLengthRef = useRef(messagesLength);
  109. const [showWelcome, setShowWelcome] = useState(!list.length);
  110. const haveBg = (!!agent?.enabledChatBg && !!agent.avatarUrl?.length)
  111. // 加载更多
  112. const onScrollToUpper = () => {
  113. console.log("onscroll");
  114. loadMore();
  115. };
  116. const handleTouchMove = () => {
  117. console.log("set auto scroll false");
  118. setAutoScroll(false);
  119. };
  120. useDidShow(() => {
  121. mutate();
  122. });
  123. useEffect(() => {
  124. if (agentId) {
  125. if (isVisitor) {
  126. fetchAgentProfile(agentId);
  127. } else {
  128. fetchAgent(agentId);
  129. }
  130. }
  131. }, [agentId, isVisitor]);
  132. // 是否显示欢迎 ui
  133. useEffect(() => {
  134. setShowWelcome(!messageList.length && !list.length);
  135. }, [list, messageList]);
  136. // 首次进入界面滚动到底
  137. // 已有:messagesLength 是一个 number(来源于 result.length)
  138. useEffect(() => {
  139. // 仅首次 pageIndex === 1 且已经有消息时滚动一次
  140. if (pageIndex === 1 && messagesLength > 0 && !initialScrolledRef.current) {
  141. initialScrolledRef.current = true;
  142. // 下1秒再滚,确保 DOM 已完成渲染
  143. setTimeout(() => setScrollTop(), 1000);
  144. }
  145. }, [pageIndex, messagesLength]);
  146. // 首次进入聊天生成 session id
  147. useEffect(() => {
  148. genSessionId();
  149. }, []);
  150. // 监听消息列表变化,触发键盘高度重新计算
  151. useEffect(() => {
  152. // 只在长度真正变化时才触发
  153. if (prevLengthRef.current !== messagesLength) {
  154. prevLengthRef.current = messagesLength;
  155. // 使用 setTimeout 确保 DOM 更新完成后再计算高度
  156. const timer = setTimeout(() => {
  157. triggerHeightUpdate();
  158. }, 100);
  159. return () => clearTimeout(timer);
  160. }
  161. }, [messagesLength, triggerHeightUpdate]);
  162. useUnload(() => {
  163. destroy();
  164. });
  165. const renderNavLeft = () => {
  166. return (
  167. <View
  168. className="flex items-center gap-8"
  169. onClick={() => Taro.navigateBack()}
  170. >
  171. {haveBg ? <IconArrowLeftWhite24 /> : <IconArrowLeft />}
  172. <PersonalCard agent={agent} haveBg={haveBg} />
  173. </View>
  174. );
  175. };
  176. // 大背景可以是视频,也可以是图片
  177. const getBgContent = () => {
  178. if (!agent?.avatarUrl || !!!agent?.enabledChatBg) {
  179. return "";
  180. }
  181. return agent?.avatarUrl;
  182. };
  183. useEffect(() => {
  184. if(haveBg){
  185. Taro.setNavigationBarColor({
  186. frontColor: "#ffffff",
  187. backgroundColor: "transparent",
  188. });
  189. }
  190. return () => {
  191. Taro.setNavigationBarColor({
  192. frontColor: "#000000",
  193. backgroundColor: "transparent",
  194. });
  195. };
  196. }, [haveBg]);
  197. useEffect(()=> {
  198. const query = Taro.createSelectorQuery();
  199. // 输入框高度
  200. query
  201. .select('#inputContainer')
  202. .boundingClientRect((rect: any) => {
  203. if (rect) {
  204. // console.log('ScrollView height:', rect.height)
  205. setInputContainerHeight(rect.height - bottomSafeHeight);
  206. }
  207. })
  208. .exec();
  209. }, [agent])
  210. return (
  211. <PageCustom
  212. fullPage
  213. style={{ overflow: "hidden" }}
  214. styleBg={getBgContent()}
  215. >
  216. <NavBarNormal blur leftColumn={renderNavLeft}>
  217. {/* <>{`${scrollTop}`}--{autoScroll ? 'true': 'false'}</> */}
  218. </NavBarNormal>
  219. <View
  220. className="flex flex-col w-full h-full relative z-10 flex-1"
  221. style={{ top: `${marginTopOffset}px` }}
  222. >
  223. <ScrollView
  224. ref={scrollViewRef}
  225. scrollY
  226. id="scrollView"
  227. style={{
  228. flex: 1,
  229. height: "1px", // 高度自适应
  230. }}
  231. scrollTop={scrollTop}
  232. scrollWithAnimation
  233. onScrollToUpper={onScrollToUpper}
  234. onScrollToLower={() => setAutoScroll(true)}
  235. >
  236. <View
  237. id="messageList"
  238. className="flex flex-col gap-16 px-18"
  239. onTouchMove={handleTouchMove}
  240. >
  241. {showWelcome && <ChatGreeting agent={agent} />}
  242. {result.map((group) => {
  243. return (
  244. <>
  245. <View className={`text-12 leading-20 block text-center w-full ${(agent?.enabledChatBg && !!agent?.avatarUrl?.length) ? 'text-white-70' : 'text-black-25'}`}>
  246. {formatMessageTime(group.dt)}
  247. </View>
  248. {group.list.map((message) => {
  249. const reasoningContent =
  250. (message as any).reasoningContent || "";
  251. return (
  252. <ChatMessage
  253. key={message.msgUk}
  254. textReasoning={reasoningContent}
  255. agent={agent}
  256. role={message.role}
  257. text={message.content}
  258. message={message}
  259. ></ChatMessage>
  260. );
  261. })}
  262. </>
  263. );
  264. })}
  265. </View>
  266. <View className="pb-40 pt-8">
  267. {agent && <RecommendQuestions enableOutputAudioStream={streamVoiceEnable} agent={agent} />}
  268. </View>
  269. </ScrollView>
  270. <View className="w-full h-60" style={{
  271. height: inputContainerHeight,
  272. }}>
  273. </View>
  274. <View
  275. className="bottom-bar px-16 pt-12 z-50"
  276. id="inputContainer"
  277. style={{
  278. bottom: `${keyboardHeight + inputContainerBottomOffset}px`,
  279. }}
  280. >
  281. <View className="bg-[#F5FAFF]">
  282. {agent &&
  283. <View className="flex flex-col w-full gap-8">
  284. <InputBar
  285. enableOutputAudioStream={streamVoiceEnable}
  286. agent={agent}
  287. histories={list}
  288. setShowWelcome={setShowWelcome}
  289. ></InputBar>
  290. <View>
  291. <ButtonEnableStreamVoice setEnable={setStreamVoiceEnable} enable={streamVoiceEnable}></ButtonEnableStreamVoice>
  292. </View>
  293. </View>
  294. }
  295. </View>
  296. </View>
  297. </View>
  298. </PageCustom>
  299. );
  300. }