index.tsx 10 KB

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