index.vue 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521
  1. <template>
  2. <view class="container">
  3. <!-- 聊天记录区域 -->
  4. <scroll-view class="chat-container" scroll-y :scroll-top="scrollTop" :scroll-with-animation="true"
  5. @scroll="onScroll" :style="{ height: `calc(100vh - ${inputHeight}px)`,marginTop: '0'}">
  6. <view class="chat-list">
  7. <view v-for="(message, index) in chatMessages" :key="index" class="message-item" :class="{ 'message-ai': message.sender === 'ai', 'message-user': message.sender === 'user' }">
  8. <!-- AI消息 -->
  9. <template v-if="message.sender === 'ai'">
  10. <view class="avatar-container">
  11. <image class="avatar" src="/static/icons/ai-1.png" mode="aspectFill"></image>
  12. </view>
  13. <view class="message-content">
  14. <view class="message-bubble ai-bubble"
  15. :class="{'typing': message.isTyping, 'welcome': message.isWelcome || index === 0}">
  16. <!-- 正在输入指示器 -->
  17. <view v-if="message.isTyping" class="typing-indicator">
  18. <view class="typing-dot"></view>
  19. <view class="typing-dot"></view>
  20. <view class="typing-dot"></view>
  21. </view>
  22. <!-- 消息内容 -->
  23. <text v-else-if="message.content" class="message-text"
  24. :class="{'highlight': containsKeywords(message.content)}">
  25. {{ message.content }}
  26. </text>
  27. <!-- AI免责声明(非欢迎消息且非输入中显示) -->
  28. <text class="ai-disclaimer">
  29. 内容均由AI生成,仅供参考
  30. </text>
  31. </view>
  32. <!-- 操作按钮区域(仅最后一条AI消息显示) -->
  33. <view v-if="index === chatMessages.length - 1 && message.sender === 'ai' && !message.isTyping" class="message-actions">
  34. <view class="action-button" @click="regenerateMessage" hover-class="action-button-hover">
  35. <text class="action-icon">🔄</text>
  36. <text class="action-text">重新生成</text>
  37. </view>
  38. </view>
  39. <text class="message-time">{{ message.time }}</text>
  40. </view>
  41. </template>
  42. <!-- 用户消息 -->
  43. <template v-else>
  44. <view class="message-content user-content">
  45. <text class="message-time">{{ message.time }}</text>
  46. <view class="message-bubble user-bubble">
  47. <text class="message-text">{{ message.content }}</text>
  48. </view>
  49. </view>
  50. <view class="avatar-container">
  51. <image class="avatar" src="/static/images/user-avatar.svg" mode="aspectFill"></image>
  52. </view>
  53. </template>
  54. </view>
  55. </view>
  56. </scroll-view>
  57. <!-- 底部输入区 -->
  58. <view class="input-container" :style="{
  59. // #ifdef H5
  60. paddingBottom: `${safeAreaBottom + 100}rpx`
  61. // #endif
  62. // #ifdef APP-HARMONY
  63. paddingBottom: `${safeAreaBottom + 20}rpx`
  64. // #endif
  65. }">
  66. <!-- 问题建议区 -->
  67. <scroll-view v-if="chatMessages.length <= 3 && !inputMessage && !isProcessing" class="suggested-questions" scroll-x>
  68. <view v-for="(question, index) in suggestedQuestions" :key="index" class="question-chip"
  69. @click="useQuestion(question)">
  70. <text>{{ question }}</text>
  71. </view>
  72. </scroll-view>
  73. <view class="input-wrapper">
  74. <textarea class="message-input" v-model="inputMessage" placeholder="请输入您的问题..." :disabled="isProcessing"
  75. auto-height :maxlength="300" :style="{ maxHeight: '120rpx' }" @focus="onInputFocus"
  76. @confirm="submitQuestion" />
  77. <!-- 中止按钮(流式输出时显示) -->
  78. <view v-if="isProcessing" class="stop-button" @click="stopStreaming" hover-class="button-hover">
  79. <text class="stop-icon">⏹</text>
  80. </view>
  81. <!-- 发送按钮 -->
  82. <view v-else class="send-button" :class="{ 'disabled': !inputMessage.trim() }"
  83. @click="submitQuestion" hover-class="button-hover">
  84. <image class="send-icon-image"
  85. :src="inputMessage.trim() ? '/static/icons/chat.png' : '/static/icons/chat_off.png'"
  86. mode="aspectFit"></image>
  87. </view>
  88. </view>
  89. </view>
  90. <!-- renderjs 模块容器(用于 H5/App 端 SSE 流式连接) -->
  91. <!-- #ifdef H5 -->
  92. <view
  93. :change:prop="renderModule.onDataChange"
  94. :prop="renderjsData"
  95. :onStreamData="onStreamData"
  96. class="renderjs-container"
  97. ></view>
  98. <!-- #endif -->
  99. </view>
  100. </template>
  101. <script setup>
  102. import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, getCurrentInstance } from 'vue'
  103. import api from "@/config/api.js";
  104. import storage from "@/utils/storage.js";
  105. import { chatStreamSuggested } from "@/api/services/chart.js";
  106. // 响应式数据
  107. const inputMessage = ref('') // 输入框消息
  108. // 初始化函数,用于获取初始时间
  109. const getInitialFormattedTime = () => {
  110. const date = new Date()
  111. const hours = date.getHours().toString().padStart(2, '0')
  112. const minutes = date.getMinutes().toString().padStart(2, '0')
  113. return `${hours}:${minutes}`
  114. }
  115. const chatMessages = ref([{
  116. sender: 'ai',
  117. content: '您好!我是农小禹,您的智能农业助手🌱 我可以帮您解答农业种植、病虫害防治、农产品管理等方面的问题。有什么可以帮助您的吗?',
  118. time: getInitialFormattedTime(),
  119. timestamp: Date.now(),
  120. isWelcome: true
  121. }])
  122. const scrollTop = ref(0)
  123. const inputHeight = ref(110)
  124. const isProcessing = ref(false)
  125. const currentTypingMessage = ref(null) // 当前正在输入的消息索引
  126. const lastUserQuestion = ref('') // 保存最后一个用户问题,用于重新生成
  127. const suggestedQuestions = ref([
  128. '水稻插秧后如何管理?',
  129. '果树夏季修剪技巧?',
  130. '如何防治蔬菜常见病虫害?',
  131. '农药使用注意事项?',
  132. '有机肥和化肥怎么搭配使用?'
  133. ])
  134. const statusBarHeight = ref(20)
  135. const safeAreaBottom = ref(0)
  136. const isIOS = ref(false)
  137. // renderjs 通信数据
  138. const renderjsData = ref({
  139. action: '', // start, stop
  140. url: '',
  141. data: {},
  142. timestamp: 0
  143. })
  144. // 消息队列和打字机效果
  145. const messageQueue = ref([])
  146. const isTypingEffect = ref(false)
  147. const typingTimer = ref(null)
  148. // Thinking 模式追踪
  149. const isInThinkingMode = ref(false)
  150. const sessionId = ref(null)
  151. const messageId = ref(null) // 消息ID,用于标识当前消息
  152. const thinkingBuffer = ref('') // 临时存储 Thinking 内容
  153. // 防抖函数引用
  154. let debouncedSubmitQuestion = null
  155. // uni-app 生命周期
  156. const onNavigationBarButtonTap = (e) => {
  157. console.log("导航栏按钮点击:", e);
  158. }
  159. // ========== renderjs 通信方法 ==========
  160. // renderjs 回调:接收流式数据
  161. const onStreamData = (data) => {
  162. console.log('=== Vue收到流式数据 ===');
  163. console.log('数据类型:', data.type);
  164. if (data.type === 'thinking') {
  165. console.log('进入 Thinking 模式');
  166. // 第一个 thinking 类型,开启 Thinking 模式
  167. isInThinkingMode.value = true;
  168. thinkingBuffer.value = data.content;
  169. processThinkingContent();
  170. } else if (data.type === 'message') {
  171. const contentLength = data.content ? data.content.length : 0;
  172. console.log('收到消息内容,长度:', contentLength);
  173. // 判断是否在 Thinking 模式中
  174. if (isInThinkingMode.value) {
  175. console.log('仍在 Thinking 模式,累积内容');
  176. // 仍在 Thinking 区域内,累积内容
  177. thinkingBuffer.value += data.content;
  178. processThinkingContent();
  179. } else {
  180. console.log('普通消息,添加到队列');
  181. // 普通消息内容
  182. handleMessageData(data.content);
  183. }
  184. } else if (data.type === 'end') {
  185. // 流式结束
  186. console.log("流式结束,消息id:", data.id);
  187. finishStreaming(data.id);
  188. } else if (data.type === 'error') {
  189. // 错误处理
  190. console.error('流式错误:', data.error);
  191. handleStreamError(data.error);
  192. }
  193. }
  194. // 处理 Thinking 内容(跳过 Thinking,只处理后续内容)
  195. const processThinkingContent = () => {
  196. if (currentTypingMessage.value === null) return;
  197. // 检查是否包含 </details> 结束标签
  198. if (thinkingBuffer.value.includes('</details>')) {
  199. // Thinking 区域结束,提取 </details> 之后的内容
  200. isInThinkingMode.value = false;
  201. // 提取 </details> 后面的内容
  202. const detailsEndIndex = thinkingBuffer.value.indexOf('</details>');
  203. const contentAfterThinking = thinkingBuffer.value.substring(detailsEndIndex + '</details>'.length);
  204. // 如果有内容,添加到消息队列
  205. if (contentAfterThinking) {
  206. handleMessageData(contentAfterThinking);
  207. }
  208. // 清空缓冲区
  209. thinkingBuffer.value = '';
  210. }
  211. // 如果还在 Thinking 区域内,不做任何显示,继续累积
  212. }
  213. // 处理消息数据(添加到队列)
  214. const handleMessageData = (text) => {
  215. console.log('handleMessageData 被调用,文本长度:', text ? text.length : 0);
  216. if (!text) return;
  217. // 将文本按字符添加到队列
  218. for (let char of text) {
  219. messageQueue.value.push(char);
  220. }
  221. console.log('消息队列长度:', messageQueue.value.length);
  222. // 如果打字机效果未启动,则启动
  223. if (!isTypingEffect.value) {
  224. console.log('启动打字机效果');
  225. startTypingEffect();
  226. }
  227. }
  228. // 启动打字机效果
  229. const startTypingEffect = () => {
  230. console.log('startTypingEffect 被调用');
  231. console.log('isTypingEffect:', isTypingEffect.value);
  232. console.log('currentTypingMessage:', currentTypingMessage.value);
  233. console.log('messageQueue 长度:', messageQueue.value.length);
  234. if (isTypingEffect.value) {
  235. console.log('打字机效果已在运行,退出');
  236. return;
  237. }
  238. if (currentTypingMessage.value === null) {
  239. console.log('没有当前消息,退出');
  240. return;
  241. }
  242. if (messageQueue.value.length === 0) {
  243. console.log('消息队列为空,退出');
  244. return;
  245. }
  246. isTypingEffect.value = true;
  247. // 关闭 typing 指示器
  248. if (chatMessages.value[currentTypingMessage.value]) {
  249. chatMessages.value[currentTypingMessage.value].isTyping = false;
  250. console.log('已关闭 typing 指示器');
  251. }
  252. console.log('开始打字机效果,消息索引:', currentTypingMessage.value);
  253. const processQueue = () => {
  254. if (messageQueue.value.length > 0 && currentTypingMessage.value !== null) {
  255. // 每次取出一个字符
  256. const char = messageQueue.value.shift();
  257. const currentMsg = chatMessages.value[currentTypingMessage.value];
  258. if (currentMsg) {
  259. const currentContent = currentMsg.content || '';
  260. currentMsg.content = currentContent + char;
  261. // 每20个字符滚动一次,优化性能
  262. if (currentContent.length % 20 === 0) {
  263. scrollToBottom();
  264. }
  265. }
  266. // 继续处理队列
  267. typingTimer.value = setTimeout(processQueue, 10);
  268. } else if (messageQueue.value.length === 0 && isProcessing.value) {
  269. // 队列为空但还在处理中,等待新数据
  270. typingTimer.value = setTimeout(processQueue, 50);
  271. } else {
  272. // 队列为空且不在处理中,停止打字机效果
  273. console.log('队列处理完成,停止打字机效果');
  274. isTypingEffect.value = false;
  275. }
  276. };
  277. processQueue();
  278. }
  279. // 停止打字机效果
  280. const stopTypingEffect = () => {
  281. isTypingEffect.value = false;
  282. if (typingTimer.value) {
  283. clearTimeout(typingTimer.value);
  284. typingTimer.value = null;
  285. }
  286. // 清空队列,将剩余内容一次性显示
  287. if (messageQueue.value.length > 0 && currentTypingMessage.value !== null) {
  288. const remainingText = messageQueue.value.join('');
  289. const currentContent = chatMessages.value[currentTypingMessage.value].content || '';
  290. chatMessages.value[currentTypingMessage.value].content = currentContent + remainingText;
  291. messageQueue.value = [];
  292. }
  293. }
  294. // 完成流式输出
  295. const finishStreaming = (id) => {
  296. console.log("消息id2:",id);
  297. stopTypingEffect();
  298. if (currentTypingMessage.value !== null) {
  299. chatMessages.value[currentTypingMessage.value].isTyping = false;
  300. currentTypingMessage.value = null;
  301. }
  302. // 重置 Thinking 模式状态
  303. isInThinkingMode.value = false;
  304. thinkingBuffer.value = '';
  305. isProcessing.value = false;
  306. scrollToBottom();
  307. // 获取下一轮建议问题
  308. // fetchSuggestedQuestions({user: sessionId.value, messageId: id});
  309. }
  310. // 处理流式错误
  311. const handleStreamError = (error) => {
  312. console.error('流式错误:', error);
  313. stopTypingEffect();
  314. // 移除正在输入的消息
  315. if (currentTypingMessage.value !== null) {
  316. chatMessages.value.splice(currentTypingMessage.value, 1);
  317. currentTypingMessage.value = null;
  318. }
  319. // 重置 Thinking 模式状态
  320. isInThinkingMode.value = false;
  321. thinkingBuffer.value = '';
  322. isProcessing.value = false;
  323. handleError({ message: error || '网络异常,请稍后重试' });
  324. }
  325. // ========== 用户交互方法 ==========
  326. // 发送消息
  327. const submitQuestion = () => {
  328. if (!storage.getHasLogin()) {
  329. uni.showModal({
  330. title: '提示',
  331. content: '您还未登录,请先登录',
  332. confirmText: '去登录',
  333. cancelText: '取消',
  334. success: function(res) {
  335. if (res.confirm) {
  336. uni.navigateTo({
  337. url: '/pages/login/index'
  338. });
  339. }
  340. },
  341. });
  342. return;
  343. }
  344. if (!inputMessage.value.trim() || isProcessing.value) return;
  345. const question = inputMessage.value.trim();
  346. lastUserQuestion.value = question; // 保存问题用于重新生成
  347. inputMessage.value = '';
  348. // 添加用户消息
  349. chatMessages.value.push({
  350. sender: 'user',
  351. content: question,
  352. time: getCurrentTime(),
  353. timestamp: Date.now()
  354. });
  355. // 添加 AI 正在输入的消息
  356. const typingMessageIndex = chatMessages.value.push({
  357. sender: 'ai',
  358. content: '',
  359. time: getCurrentTime(),
  360. timestamp: Date.now(),
  361. isTyping: true
  362. }) - 1;
  363. currentTypingMessage.value = typingMessageIndex;
  364. isProcessing.value = true;
  365. scrollToBottom();
  366. // 通过 renderjs 发起 SSE 请求
  367. startSSERequest(question);
  368. }
  369. // 启动 SSE 请求(通过 renderjs)
  370. const startSSERequest = (question) => {
  371. const url = api.serve + '/uniapp/dify/chat/stream';
  372. sessionId.value = Date.now().toString()
  373. const requestData = {
  374. query: question,
  375. user: 'user_' + sessionId.value
  376. };
  377. // #ifdef H5
  378. // 更新 renderjs 数据,触发 SSE 连接
  379. renderjsData.value = {
  380. action: 'start',
  381. url: url,
  382. data: requestData,
  383. token: storage.getAccessToken(),
  384. timestamp: Date.now()
  385. };
  386. // #endif
  387. // #ifdef APP-HARMONY || APP-PLUS
  388. // 鸿蒙等不支持 renderjs 的平台,使用原生方式
  389. startSSERequestNative(url, requestData);
  390. // #endif
  391. }
  392. // 鸿蒙端 SSE 请求任务引用
  393. let sseRequestTask = null;
  394. // 原生方式处理 SSE 请求(用于鸿蒙等平台)
  395. const startSSERequestNative = (url, data) => {
  396. console.log('[鸿蒙端] 开始 SSE 请求:', url);
  397. // 创建请求任务
  398. sseRequestTask = uni.request({
  399. url: url,
  400. method: 'POST',
  401. header: {
  402. 'Content-Type': 'application/json',
  403. 'Accept': 'text/event-stream',
  404. 'Authorization': `Bearer ${storage.getAccessToken()}`
  405. },
  406. data: data,
  407. enableChunked: true, // 启用分块传输
  408. responseType: 'text', // 接收文本数据
  409. success: (res) => {
  410. console.log('[鸿蒙端] 请求成功,状态码:', res.statusCode);
  411. console.log('[鸿蒙端] 响应数据类型:', typeof res.data);
  412. if (res.statusCode === 200 && res.data) {
  413. console.log('[鸿蒙端] 响应数据长度:', res.data.length);
  414. // 处理 SSE 格式的响应数据
  415. parseSSEResponse(res.data);
  416. } else {
  417. console.error('[鸿蒙端] 请求失败或无数据');
  418. handleStreamError('请求失败,状态码: ' + res.statusCode);
  419. }
  420. },
  421. fail: (err) => {
  422. console.error('[鸿蒙端] 请求失败:', err);
  423. handleStreamError(err.errMsg || '网络异常');
  424. }
  425. });
  426. // 监听数据接收(如果支持分块传输)
  427. if (sseRequestTask && typeof sseRequestTask.onChunkReceived === 'function') {
  428. console.log('[鸿蒙端] 支持分块接收,启用监听');
  429. sseRequestTask.onChunkReceived((chunkRes) => {
  430. console.log('[鸿蒙端] 收到数据块');
  431. if (chunkRes && chunkRes.data) {
  432. parseSSEResponseChunk(chunkRes.data);
  433. }
  434. });
  435. } else {
  436. console.log('[鸿蒙端] 不支持分块接收,将在 success 回调中处理完整响应');
  437. }
  438. }
  439. // 解析 SSE 格式的响应数据(处理分块数据)
  440. let sseBuffer = ''; // 用于累积不完整的行
  441. const parseSSEResponseChunk = (chunkText) => {
  442. if (!chunkText || typeof chunkText !== 'string') {
  443. console.log('[鸿蒙端] 无效的数据块');
  444. return;
  445. }
  446. console.log('[鸿蒙端] 处理数据块,长度:', chunkText.length);
  447. // 累积到缓冲区
  448. sseBuffer += chunkText;
  449. // 按行分割
  450. const lines = sseBuffer.split('\n');
  451. // 保留最后一行(可能不完整)
  452. sseBuffer = lines.pop() || '';
  453. // 处理完整的行
  454. processSSELines(lines);
  455. }
  456. // 解析 SSE 格式的响应数据(处理完整响应)
  457. const parseSSEResponse = (responseText) => {
  458. if (!responseText || typeof responseText !== 'string') {
  459. console.log('[鸿蒙端] 无效的响应数据');
  460. finishStreaming();
  461. return;
  462. }
  463. console.log('[鸿蒙端] 开始解析完整 SSE 数据,长度:', responseText.length);
  464. // 按行分割
  465. const lines = responseText.split('\n');
  466. // 处理所有行
  467. processSSELines(lines);
  468. // 清空缓冲区
  469. sseBuffer = '';
  470. }
  471. // 处理 SSE 行数据
  472. const processSSELines = (lines) => {
  473. let messageId = null;
  474. for (let i = 0; i < lines.length; i++) {
  475. const line = lines[i].trim();
  476. if (!line) continue; // 跳过空行
  477. console.log(`[鸿蒙端] 处理第 ${i} 行:`, line.substring(0, 100));
  478. // 解析 event: 行
  479. if (line.startsWith('event:')) {
  480. // event: message
  481. continue;
  482. }
  483. // 解析 data: 行
  484. if (line.startsWith('data:') || line !== '') {
  485. let data = line;
  486. if (line.startsWith('data:')) {
  487. data = data.substring(5).trim();
  488. }
  489. if (!data || data.includes("ping")) continue;
  490. console.log('[鸿蒙端] 解析后的数据,长度:', data.length);
  491. // 过滤掉 MESSAGE_END 等元数据事件(JSON 格式)
  492. if (data.startsWith('{') && data.includes('"eventType"')) {
  493. try {
  494. const jsonData = JSON.parse(data);
  495. console.log('[鸿蒙端] JSON 事件:', jsonData.eventType || jsonData.event);
  496. // 如果是 MESSAGE_END 事件,通知结束
  497. if (jsonData.eventType === 'MESSAGE_END' || jsonData.event === 'message_end') {
  498. console.log('[鸿蒙端] 收到 MESSAGE_END 事件,流式结束');
  499. messageId = jsonData.id;
  500. // 不要在这里 continue,继续处理后续行
  501. continue;
  502. }
  503. // 其他元数据事件也忽略
  504. if (jsonData.eventType || jsonData.event) {
  505. console.log('[鸿蒙端] 跳过元数据事件');
  506. continue;
  507. }
  508. } catch (e) {
  509. console.log('[鸿蒙端] JSON 解析失败,作为普通文本处理');
  510. // 不是 JSON,继续处理
  511. }
  512. }
  513. // 检查是否是 Thinking 内容
  514. if (data.includes('<details') && data.includes('<summary>')) {
  515. console.log('[鸿蒙端] 发送 thinking 类型数据');
  516. onStreamData({ type: 'thinking', content: data });
  517. } else if (data) {
  518. // 普通消息内容 - 逐行发送,不累积
  519. console.log('[鸿蒙端] 发送 message 类型数据,长度:', data.length);
  520. onStreamData({ type: 'message', content: data });
  521. }
  522. }
  523. }
  524. // 如果找到了 messageId,结束流式输出
  525. if (messageId) {
  526. console.log('[鸿蒙端] 数据处理完成,结束流式输出');
  527. setTimeout(() => {
  528. onStreamData({ type: 'end', id: messageId });
  529. }, 300);
  530. } else {
  531. // 如果没有 messageId,延迟结束(兼容处理)
  532. console.log('[鸿蒙端] 无 messageId,延迟结束');
  533. setTimeout(() => {
  534. if (isProcessing.value) {
  535. onStreamData({ type: 'end', id: null });
  536. }
  537. }, 1000);
  538. }
  539. }
  540. // 停止鸿蒙端的 SSE 请求
  541. const stopSSERequestNative = () => {
  542. if (sseRequestTask) {
  543. console.log('[鸿蒙端] 中止请求');
  544. sseRequestTask.abort();
  545. sseRequestTask = null;
  546. }
  547. }
  548. // 停止流式输出
  549. const stopStreaming = () => {
  550. console.log('用户中止流式输出');
  551. // #ifdef H5
  552. // 通知 renderjs 停止
  553. renderjsData.value = {
  554. action: 'stop',
  555. timestamp: Date.now()
  556. };
  557. // #endif
  558. // #ifdef APP-HARMONY
  559. // 停止鸿蒙端的请求
  560. stopSSERequestNative();
  561. // #endif
  562. // 立即停止打字机效果并完成
  563. finishStreaming();
  564. uni.showToast({
  565. title: '已中止',
  566. icon: 'none',
  567. duration: 1500
  568. });
  569. }
  570. // 重新生成回复
  571. const regenerateMessage = () => {
  572. if (!lastUserQuestion.value || isProcessing.value) return;
  573. // 删除最后一条 AI 消息
  574. if (chatMessages.value.length > 0 && chatMessages.value[chatMessages.value.length - 1].sender === 'ai') {
  575. chatMessages.value.pop();
  576. }
  577. // 添加新的正在输入消息
  578. const typingMessageIndex = chatMessages.value.push({
  579. sender: 'ai',
  580. content: '',
  581. time: getCurrentTime(),
  582. timestamp: Date.now(),
  583. isTyping: true
  584. }) - 1;
  585. currentTypingMessage.value = typingMessageIndex;
  586. isProcessing.value = true;
  587. messageQueue.value = [];
  588. // 重置 Thinking 模式状态
  589. isInThinkingMode.value = false;
  590. thinkingBuffer.value = '';
  591. scrollToBottom();
  592. // 重新发起请求
  593. startSSERequest(lastUserQuestion.value);
  594. }
  595. // ========== 工具方法 ==========
  596. // 错误处理
  597. const handleError = (error) => {
  598. let errorMessage = '发生错误';
  599. if (error.errMsg) {
  600. errorMessage = error.errMsg;
  601. } else if (error.message) {
  602. errorMessage = error.message;
  603. }
  604. uni.showToast({
  605. title: errorMessage,
  606. icon: 'none',
  607. duration: 2000
  608. });
  609. }
  610. // 防抖函数
  611. const debounce = (func, wait) => {
  612. let timeout;
  613. return (...args) => {
  614. clearTimeout(timeout);
  615. timeout = setTimeout(() => {
  616. func.apply(null, args);
  617. }, wait);
  618. };
  619. }
  620. // 输入框获取焦点
  621. const onInputFocus = () => {
  622. nextTick(() => {
  623. scrollToBottom();
  624. });
  625. }
  626. // 滚动到底部
  627. const scrollToBottom = () => {
  628. nextTick(() => {
  629. const query = uni.createSelectorQuery();
  630. query.select('.chat-list').boundingClientRect(data => {
  631. if (data) {
  632. scrollTop.value = data.height + 1000;
  633. }
  634. }).exec();
  635. });
  636. }
  637. const onScroll = (e) => {
  638. // 可以添加滚动事件处理
  639. }
  640. const getCurrentTime = () => {
  641. return getFormattedTime(new Date());
  642. }
  643. const getFormattedTime = (date) => {
  644. const hours = date.getHours().toString().padStart(2, '0');
  645. const minutes = date.getMinutes().toString().padStart(2, '0');
  646. return `${hours}:${minutes}`;
  647. }
  648. const useQuestion = (question) => {
  649. inputMessage.value = question;
  650. }
  651. const containsKeywords = (text) => {
  652. const keywords = ['水稻', '小麦', '玉米', '病虫害', '农药', '化肥', '有机肥', '种植技术'];
  653. return keywords.some(keyword => text.includes(keyword));
  654. }
  655. const formatMessage = (text) => {
  656. // 将文本中的换行符转换为<br>标签
  657. return text.replace(/\n/g, '<br>');
  658. }
  659. const initMessages = () => {
  660. // 确保消息有时间戳
  661. chatMessages.value.forEach(msg => {
  662. if (!msg.timestamp) {
  663. msg.timestamp = new Date().getTime();
  664. }
  665. });
  666. // 按时间排序
  667. chatMessages.value.sort((a, b) => a.timestamp - b.timestamp);
  668. }
  669. const showDateSeparator = (index) => {
  670. // 判断是否需要显示日期分割线
  671. if (index === 0) return true;
  672. const currentMsg = chatMessages.value[index];
  673. const prevMsg = chatMessages.value[index - 1];
  674. // 如果两条消息相隔超过30分钟,或者是不同日期,显示日期分割线
  675. return isDifferentDay(currentMsg.timestamp, prevMsg.timestamp) ||
  676. (currentMsg.timestamp - prevMsg.timestamp > 30 * 60 * 1000);
  677. }
  678. const isDifferentDay = (timestamp1, timestamp2) => {
  679. const date1 = new Date(timestamp1);
  680. const date2 = new Date(timestamp2);
  681. return date1.getDate() !== date2.getDate() ||
  682. date1.getMonth() !== date2.getMonth() ||
  683. date1.getFullYear() !== date2.getFullYear();
  684. }
  685. const formatDateSeparator = (timestamp) => {
  686. const now = new Date();
  687. const msgDate = new Date(timestamp);
  688. // 今天
  689. if (isSameDay(msgDate, now)) {
  690. return '今天 ' + getFormattedTime(msgDate);
  691. }
  692. // 昨天
  693. const yesterday = new Date(now);
  694. yesterday.setDate(now.getDate() - 1);
  695. if (isSameDay(msgDate, yesterday)) {
  696. return '昨天 ' + getFormattedTime(msgDate);
  697. }
  698. // 一周内
  699. const oneWeekAgo = new Date(now);
  700. oneWeekAgo.setDate(now.getDate() - 7);
  701. if (msgDate >= oneWeekAgo) {
  702. const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
  703. return weekdays[msgDate.getDay()] + ' ' + getFormattedTime(msgDate);
  704. }
  705. // 其他日期
  706. return msgDate.getFullYear() + '年' + (msgDate.getMonth() + 1) + '月' + msgDate.getDate() + '日 ' + getFormattedTime(msgDate);
  707. }
  708. const isSameDay = (date1, date2) => {
  709. return date1.getDate() === date2.getDate() &&
  710. date1.getMonth() === date2.getMonth() &&
  711. date1.getFullYear() === date2.getFullYear();
  712. }
  713. // 获取下一轮建议问题列表
  714. const fetchSuggestedQuestions = async (data) => {
  715. console.log("获取下一轮建议问题参数",data);
  716. try {
  717. const response = await chatStreamSuggested(data);
  718. if (response && response.data) {
  719. // 假设接口返回的数据格式为 { data: ["问题1", "问题2", ...] }
  720. if (Array.isArray(response.data) && response.data.length > 0) {
  721. suggestedQuestions.value = response.data;
  722. } else if (response.data.questions && Array.isArray(response.data.questions)) {
  723. // 或者接口返回 { data: { questions: [...] } }
  724. suggestedQuestions.value = response.data.questions;
  725. }
  726. }
  727. } catch (error) {
  728. console.error('获取建议问题失败:', error);
  729. // 失败时保持默认建议问题,不影响用户体验
  730. }
  731. }
  732. // 生命周期钩子
  733. onMounted(() => {
  734. // 初始化防抖函数
  735. debouncedSubmitQuestion = debounce(submitQuestion, 300)
  736. // 获取系统信息
  737. const systemInfo = uni.getSystemInfoSync();
  738. statusBarHeight.value = systemInfo.statusBarHeight || 20;
  739. isIOS.value = systemInfo.platform === 'ios';
  740. // 计算底部安全区域(包含 tabbar 高度)
  741. const safeAreaInsetsBottom = systemInfo.safeAreaInsets ? (systemInfo.safeAreaInsets.bottom || 0) : 0;
  742. // 转换为 rpx,并确保至少有基础间距
  743. safeAreaBottom.value = Math.max(safeAreaInsetsBottom * 2, 0);
  744. // 初始化消息
  745. initMessages();
  746. // 滚动到底部
  747. nextTick(() => {
  748. scrollToBottom();
  749. });
  750. // 监听来自 renderjs 的自定义事件(仅在支持的平台)
  751. // #ifdef H5 || APP-PLUS
  752. if (typeof window !== 'undefined') {
  753. window.addEventListener('renderjs-stream-data', (event) => {
  754. console.log('通过事件接收到数据:', event.detail);
  755. onStreamData(event.detail);
  756. });
  757. }
  758. // #endif
  759. })
  760. // 组件销毁时清理资源
  761. onBeforeUnmount(() => {
  762. // 停止打字机效果
  763. stopTypingEffect();
  764. // 通知 renderjs 停止连接
  765. if (isProcessing.value) {
  766. renderjsData.value = {
  767. action: 'stop',
  768. timestamp: Date.now()
  769. };
  770. }
  771. // 清理消息队列
  772. messageQueue.value = [];
  773. // 移除事件监听器(仅在支持的平台)
  774. // #ifdef H5 || APP-PLUS
  775. if (typeof window !== 'undefined') {
  776. window.removeEventListener('renderjs-stream-data', onStreamData);
  777. }
  778. // #endif
  779. })
  780. </script>
  781. <!-- #ifdef H5 || APP-PLUS -->
  782. <script module="renderModule" lang="renderjs">
  783. // renderjs 模块状态
  784. let eventSource = null;
  785. let reader = null;
  786. let isReading = false;
  787. let eventType = '';
  788. let dataBuffer = [];
  789. let messageIdS = null;
  790. // 启动 SSE 连接
  791. async function startSSE(config, ownerInstance) {
  792. // 先停止之前的连接
  793. stopSSE();
  794. const { url, data, token } = config;
  795. try {
  796. // 使用 fetch API 建立 SSE 连接
  797. const response = await fetch(url, {
  798. method: 'POST',
  799. headers: {
  800. 'Content-Type': 'application/json',
  801. 'Accept': 'text/event-stream',
  802. 'Authorization': `Bearer ${token}`
  803. },
  804. body: JSON.stringify(data)
  805. });
  806. if (!response.ok) {
  807. throw new Error(`HTTP error! status: ${response.status}`);
  808. }
  809. // 获取 reader
  810. reader = response.body.getReader();
  811. const decoder = new TextDecoder('utf-8');
  812. isReading = true;
  813. let buffer = '';
  814. eventType = ''; // 当前事件名
  815. dataBuffer = []; // 当前事件的所有 data 行
  816. // 读取流式数据
  817. while (isReading) {
  818. const { done, value } = await reader.read();
  819. if (done) {
  820. console.log('SSE 流结束');
  821. if (typeof window !== 'undefined') {
  822. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  823. detail: { type: 'end', id: messageIdS }
  824. }));
  825. }
  826. break;
  827. }
  828. // 解码数据
  829. buffer += decoder.decode(value, { stream: true });
  830. // 按行处理
  831. const lines = buffer.split('\n');
  832. buffer = lines.pop() || ''; // 保留最后不完整的行
  833. for (const line of lines) {
  834. processLine(line, ownerInstance);
  835. }
  836. }
  837. } catch (error) {
  838. console.error('SSE 连接错误:', error);
  839. if (typeof window !== 'undefined') {
  840. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  841. detail: {
  842. type: 'error',
  843. error: error.message || '连接失败'
  844. }
  845. }));
  846. }
  847. } finally {
  848. stopSSE();
  849. }
  850. }
  851. // 处理单行数据
  852. function processLine(line, ownerInstance) {
  853. if (!line.trim()) return;
  854. console.log('[renderjs] 处理行数据:', line);
  855. // 解析 SSE 格式
  856. if (line.startsWith('event:')) {
  857. // event: message
  858. return;
  859. }
  860. if (line.startsWith('data:') || line !== '') {
  861. let data = line;
  862. if (line.startsWith('data:')) {
  863. data = data.substring(5).trim();
  864. }
  865. if (!data || data.includes("ping")) return;
  866. console.log('[renderjs] 解析后的数据:', data);
  867. // 过滤掉 MESSAGE_END 等元数据事件(JSON 格式)
  868. if (data.startsWith('{') && data.includes('"eventType"')) {
  869. try {
  870. const jsonData = JSON.parse(data);
  871. console.log('[renderjs] JSON 事件:', jsonData);
  872. // 如果是 MESSAGE_END 事件,通知结束
  873. if (jsonData.eventType === 'MESSAGE_END' || jsonData.event === 'message_end') {
  874. console.log('[renderjs] 收到 MESSAGE_END 事件,流式结束');
  875. messageIdS = jsonData.id;
  876. if (typeof window !== 'undefined') {
  877. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  878. detail: { type: 'end', id: messageIdS }
  879. }));
  880. }
  881. return;
  882. }
  883. // 其他元数据事件也忽略
  884. return;
  885. } catch (e) {
  886. console.log('[renderjs] JSON 解析失败,作为普通文本处理');
  887. // 不是 JSON,继续处理
  888. }
  889. }
  890. // 检查是否是 Thinking 内容
  891. if (data.includes('<details') && data.includes('<summary>')) {
  892. console.log('[renderjs] 发送 thinking 类型数据');
  893. // 使用自定义事件发送数据
  894. if (typeof window !== 'undefined') {
  895. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  896. detail: {
  897. type: 'thinking',
  898. content: data
  899. }
  900. }));
  901. }
  902. } else {
  903. console.log('[renderjs] 发送 message 类型数据,长度:', data.length);
  904. // 普通消息内容
  905. if (typeof window !== 'undefined') {
  906. window.dispatchEvent(new CustomEvent('renderjs-stream-data', {
  907. detail: {
  908. type: 'message',
  909. content: data
  910. }
  911. }));
  912. }
  913. }
  914. }
  915. }
  916. // 停止 SSE 连接
  917. function stopSSE() {
  918. isReading = false;
  919. if (reader) {
  920. try {
  921. reader.cancel();
  922. } catch (e) {
  923. console.error('关闭 reader 失败:', e);
  924. }
  925. reader = null;
  926. }
  927. if (eventSource) {
  928. eventSource.close();
  929. eventSource = null;
  930. }
  931. }
  932. // 导出方法供 Vue 调用
  933. export default {
  934. methods: {
  935. // 监听 prop 变化
  936. onDataChange(newValue, oldValue, ownerInstance) {
  937. if (!newValue || !newValue.action) return;
  938. if (newValue.action === 'start') {
  939. startSSE(newValue, ownerInstance);
  940. } else if (newValue.action === 'stop') {
  941. stopSSE();
  942. }
  943. }
  944. }
  945. };
  946. </script>
  947. <!-- #endif -->
  948. <style>
  949. /* 容器样式 */
  950. .container {
  951. position: relative;
  952. min-height: 100vh;
  953. background-color: #f5f5f5;
  954. overflow: hidden;
  955. /* 防止内容溢出 */
  956. }
  957. /* 聊天容器 */
  958. .chat-container {
  959. padding: 20rpx 30rpx;
  960. box-sizing: border-box;
  961. background-color: #f8f8f8;
  962. background-image: url('/static/images/chat-bg-pattern.png');
  963. background-size: 300rpx;
  964. background-blend-mode: overlay;
  965. background-opacity: 0.05;
  966. -webkit-overflow-scrolling: touch;
  967. /* 增强iOS滚动体验 */
  968. }
  969. .chat-list {
  970. padding-bottom: 30rpx;
  971. }
  972. /* 消息项 */
  973. .message-item {
  974. display: flex;
  975. margin-bottom: 30rpx;
  976. position: relative;
  977. }
  978. .message-ai {
  979. justify-content: flex-start;
  980. }
  981. .message-user {
  982. justify-content: flex-end;
  983. }
  984. /* 头像 */
  985. .avatar-container {
  986. width: 90rpx;
  987. height: 90rpx;
  988. flex-shrink: 0;
  989. }
  990. .avatar {
  991. width: 90rpx;
  992. height: 90rpx;
  993. border-radius: 50%;
  994. background-color: #e0e0e0;
  995. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  996. object-fit: cover;
  997. }
  998. /* 消息内容 */
  999. .message-content {
  1000. max-width: 70%;
  1001. margin: 0 20rpx;
  1002. display: flex;
  1003. flex-direction: column;
  1004. }
  1005. .user-content {
  1006. align-items: flex-end;
  1007. }
  1008. .message-bubble {
  1009. padding: 24rpx;
  1010. border-radius: 24rpx;
  1011. position: relative;
  1012. margin-bottom: 10rpx;
  1013. word-wrap: break-word;
  1014. min-width: 80rpx;
  1015. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
  1016. transition: all 0.3s ease;
  1017. max-width: 100%;
  1018. }
  1019. .ai-bubble {
  1020. background-color: #e8f5e9;
  1021. border-top-left-radius: 4rpx;
  1022. }
  1023. .user-bubble {
  1024. background-color: #e3f2fd;
  1025. border-top-right-radius: 4rpx;
  1026. }
  1027. .message-text {
  1028. font-size: 28rpx;
  1029. color: #333;
  1030. line-height: 1.5;
  1031. word-break: break-all;
  1032. }
  1033. .message-time {
  1034. font-size: 22rpx;
  1035. color: #999;
  1036. }
  1037. /* 消息操作按钮 */
  1038. .message-actions {
  1039. display: flex;
  1040. gap: 16rpx;
  1041. margin-top: 12rpx;
  1042. }
  1043. .action-button {
  1044. display: flex;
  1045. align-items: center;
  1046. padding: 8rpx 16rpx;
  1047. background-color: #f5f5f5;
  1048. border-radius: 20rpx;
  1049. border: 1rpx solid #e0e0e0;
  1050. transition: all 0.2s;
  1051. }
  1052. .action-button-hover {
  1053. background-color: #e8f5e9;
  1054. border-color: #a5d6a7;
  1055. }
  1056. .action-icon {
  1057. font-size: 24rpx;
  1058. margin-right: 6rpx;
  1059. }
  1060. .action-text {
  1061. font-size: 24rpx;
  1062. color: #666;
  1063. }
  1064. /* 输入区域 */
  1065. .input-container {
  1066. position: fixed;
  1067. bottom: 0;
  1068. left: 0;
  1069. right: 0;
  1070. background-color: #fff;
  1071. padding: 20rpx 30rpx;
  1072. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.1);
  1073. display: flex;
  1074. flex-direction: column;
  1075. /* z-index: 999; */
  1076. /* 确保在 tabbar 之上 */
  1077. }
  1078. .input-wrapper {
  1079. display: flex;
  1080. align-items: flex-end;
  1081. }
  1082. .message-input {
  1083. flex: 1;
  1084. min-height: 70rpx;
  1085. max-height: 120rpx;
  1086. border-radius: 35rpx;
  1087. background-color: #f5f5f5;
  1088. padding: 15rpx 30rpx;
  1089. font-size: 28rpx;
  1090. color: #333;
  1091. border: 1rpx solid #e0e0e0;
  1092. line-height: 1.4;
  1093. }
  1094. .send-button {
  1095. margin-left: 16rpx;
  1096. width: 76rpx;
  1097. height: 76rpx;
  1098. border-radius: 50%;
  1099. background-color: transparent;
  1100. background-image: none;
  1101. display: flex;
  1102. align-items: center;
  1103. justify-content: center;
  1104. transition: all 0.2s ease;
  1105. position: relative;
  1106. align-self: center;
  1107. }
  1108. .send-button.disabled {
  1109. background-color: transparent;
  1110. background-image: none;
  1111. opacity: 1;
  1112. }
  1113. /* 中止按钮 */
  1114. .stop-button {
  1115. margin-left: 16rpx;
  1116. width: 76rpx;
  1117. height: 76rpx;
  1118. border-radius: 50%;
  1119. background-color: #ff5252;
  1120. display: flex;
  1121. align-items: center;
  1122. justify-content: center;
  1123. transition: all 0.2s ease;
  1124. align-self: center;
  1125. }
  1126. .stop-icon {
  1127. font-size: 36rpx;
  1128. color: white;
  1129. }
  1130. .button-hover {
  1131. transform: scale(0.95);
  1132. }
  1133. @keyframes pulse {
  1134. 0% {
  1135. transform: scale(1);
  1136. }
  1137. 50% {
  1138. transform: scale(0.95);
  1139. }
  1140. 100% {
  1141. transform: scale(1);
  1142. }
  1143. }
  1144. .send-button:active:not(.disabled) {
  1145. animation: pulse 0.3s ease-in-out;
  1146. }
  1147. /* 删除或注释掉之前的样式 */
  1148. .send-icon {
  1149. display: none;
  1150. }
  1151. .send-icon:before {
  1152. display: none;
  1153. }
  1154. .send-icon-text {
  1155. display: none;
  1156. }
  1157. /* 推荐问题区域 */
  1158. .suggested-questions {
  1159. display: flex;
  1160. white-space: nowrap;
  1161. margin-bottom: 15rpx;
  1162. padding: 5rpx 0;
  1163. }
  1164. .question-chip {
  1165. display: inline-block;
  1166. padding: 12rpx 20rpx;
  1167. margin-right: 15rpx;
  1168. background-color: #e8f5e9;
  1169. color: #4CAF50;
  1170. font-size: 24rpx;
  1171. border-radius: 30rpx;
  1172. border: 1rpx solid #a5d6a7;
  1173. }
  1174. /* AI正在输入的样式 */
  1175. .message-bubble.ai-bubble.typing {
  1176. background-color: #f0f0f0;
  1177. }
  1178. .typing-indicator {
  1179. display: flex;
  1180. align-items: center;
  1181. justify-content: center;
  1182. height: 40rpx;
  1183. padding: 0 20rpx;
  1184. }
  1185. .typing-dot {
  1186. width: 10rpx;
  1187. height: 10rpx;
  1188. margin: 0 5rpx;
  1189. background-color: #4CAF50;
  1190. border-radius: 50%;
  1191. opacity: 0.5;
  1192. animation: typingAnimation 1.4s infinite both;
  1193. }
  1194. .typing-dot:nth-child(2) {
  1195. animation-delay: 0.2s;
  1196. }
  1197. .typing-dot:nth-child(3) {
  1198. animation-delay: 0.4s;
  1199. }
  1200. @keyframes typingAnimation {
  1201. 0% {
  1202. opacity: 0.3;
  1203. transform: translateY(0);
  1204. }
  1205. 50% {
  1206. opacity: 1;
  1207. transform: translateY(-5rpx);
  1208. }
  1209. 100% {
  1210. opacity: 0.3;
  1211. transform: translateY(0);
  1212. }
  1213. }
  1214. /* 关键词高亮 */
  1215. .message-text.highlight {
  1216. color: #2E7D32;
  1217. font-weight: 500;
  1218. }
  1219. /* AI免责声明 */
  1220. .ai-disclaimer {
  1221. display: block;
  1222. font-size: 20rpx;
  1223. color: #999;
  1224. margin-top: 12rpx;
  1225. padding-top: 8rpx;
  1226. border-top: 1rpx solid rgba(0, 0, 0, 0.05);
  1227. line-height: 1.4;
  1228. }
  1229. /* 欢迎消息特殊样式 */
  1230. .welcome {
  1231. background-color: #e3f2fd !important;
  1232. border-left: none !important;
  1233. border-radius: 24rpx !important;
  1234. }
  1235. /* 日期分割线样式 */
  1236. .date-separator {
  1237. display: flex;
  1238. align-items: center;
  1239. justify-content: center;
  1240. margin: 20rpx 0;
  1241. }
  1242. .date-separator text {
  1243. background-color: rgba(0, 0, 0, 0.1);
  1244. color: #666;
  1245. font-size: 24rpx;
  1246. padding: 4rpx 20rpx;
  1247. border-radius: 20rpx;
  1248. }
  1249. /* 纸飞机图标 */
  1250. .plane-svg {
  1251. display: none;
  1252. }
  1253. .send-icon-image {
  1254. width: 76rpx;
  1255. height: 76rpx;
  1256. }
  1257. .material-icon {
  1258. font-family: 'Material Icons';
  1259. font-weight: normal;
  1260. font-style: normal;
  1261. font-size: 48rpx;
  1262. line-height: 1;
  1263. letter-spacing: normal;
  1264. text-transform: none;
  1265. display: inline-block;
  1266. white-space: nowrap;
  1267. word-wrap: normal;
  1268. direction: ltr;
  1269. -webkit-font-smoothing: antialiased;
  1270. color: white;
  1271. }
  1272. /* 删除不需要的导航栏样式 */
  1273. .custom-navbar {
  1274. display: none;
  1275. }
  1276. .navbar-bg,
  1277. .navbar-content,
  1278. .navbar-left,
  1279. .navbar-title,
  1280. .navbar-right,
  1281. .back-icon,
  1282. .arrow-left {
  1283. display: none;
  1284. }
  1285. /* renderjs 容器(隐藏) */
  1286. .renderjs-container {
  1287. display: none;
  1288. width: 0;
  1289. height: 0;
  1290. opacity: 0;
  1291. position: absolute;
  1292. pointer-events: none;
  1293. }
  1294. </style>