AiChat.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. <template>
  2. <div class="chat-content-ai">
  3. <div ref="chatArea" class="chat-message">
  4. <div v-if="showMore" class="more" @click="handleMoreHistory"> 查看历史记录 </div>
  5. <div class="chat-message-scroll" :class="{ 'is-full': isFull }">
  6. <!-- sender:发送人,replyer:回复人 -->
  7. <div
  8. v-for="(message, index) in messageList"
  9. :key="message.id || index"
  10. class="chat-message-box"
  11. :class="[message.author === 'sender' ? 'message-sender' : 'message-replyer']"
  12. >
  13. <div v-if="message.showTime" class="other time">
  14. <span>{{ dateFormatter(message.showTime, 'yyyy年M月d日 HH:mm:ss', true) }}</span>
  15. </div>
  16. <!-- 双方头像 -->
  17. <div class="chat-message-avatar">
  18. <img :src="message.author === 'sender' ? ManAvatar : AiAvatar" alt="" />
  19. </div>
  20. <div class="chat-message-content">
  21. <!-- 消息主内容 -->
  22. <div class="chat-message-content__message">
  23. <!-- 消息部分 -->
  24. <template v-if="message.body.component">
  25. <component :is="message.body.component" v-bind="message" />
  26. </template>
  27. <div v-else class="message-text" v-html="message.body.text"></div>
  28. </div>
  29. </div>
  30. </div>
  31. </div>
  32. </div>
  33. <!-- 聊天框下方操作部分 -->
  34. <ChatBottom @handleSend="handleOutboundMessage" />
  35. </div>
  36. </template>
  37. <script lang="ts" setup>
  38. import { dateFormatter } from '../utils'
  39. import ManAvatar from '../img/chat/man-avatar.png'
  40. import AiAvatar from '../img/chat/ai-avatar-base.png'
  41. import ChatBottom from './ChatBottom.vue'
  42. import Thinking from './Thinking.vue'
  43. import { marked } from 'marked'
  44. import { ref, watch, onUnmounted, nextTick } from 'vue'
  45. import {Recordable} from "vite-plugin-mock";
  46. const props = defineProps({
  47. isOpen: {
  48. type: Boolean,
  49. default: false
  50. }
  51. })
  52. const emit = defineEmits(['handleSend'])
  53. const aiName = 'AI机器人'
  54. const isInit = ref(false) // 判断是否已经初始化 否则不监听滚动
  55. watch(
  56. () => props.isOpen,
  57. (val) => {
  58. // 打开弹窗时 有历史记录 则需要自动滚动到最下面
  59. if (val) {
  60. init()
  61. setTimeout(() => {
  62. isInit.value = false
  63. }, 500)
  64. } else reset()
  65. }
  66. )
  67. // 销毁(按需)
  68. onUnmounted(() => {
  69. document.querySelector('.chat-message')?.removeEventListener('scroll', () => {})
  70. })
  71. const isFull = ref(false) // 消息是否充满一页 用于首次加载滚动
  72. const showMore = ref(false) // 点击可以查看更多
  73. const messageList = ref<Recordable[]>([]) // 消息列表
  74. const historyList = ref<Recordable[]>([]) // 历史消息列表
  75. // 整理、移除历史记录 仅记录7天内
  76. const trimHistory = () => {
  77. const historyChat =
  78. localStorage.getItem('chat') && JSON.parse(localStorage.getItem('chat') as string)
  79. if (historyChat && historyChat.length) {
  80. const findIndex = historyChat.findIndex((i) => {
  81. const time = i.date
  82. const now = new Date().getTime()
  83. const day = 24 * 60 * 60 * 1000 * 7
  84. return now - time > day
  85. })
  86. if (findIndex !== -1) {
  87. const list = historyChat.slice(0, findIndex)
  88. localStorage.setItem('chat', JSON.stringify(list))
  89. return list
  90. } else return historyChat
  91. } else return []
  92. }
  93. const init = () => {
  94. historyList.value = trimHistory()
  95. // 有历史记录,默认展示historyLimit条
  96. if (historyList.value.length) {
  97. showMore.value = true
  98. isFull.value = true
  99. }
  100. // 最新的一条记录(欢迎语), 所以记录这个时间
  101. const newRecordTime = Date.now()
  102. // 新增欢迎语 每次初始化都来一次(但不计入历史记录中)
  103. const record = {
  104. body: {
  105. text: 'Hi~我是AI机器人,我可以帮你什么?'
  106. },
  107. date: newRecordTime,
  108. showTime: newRecordTime,
  109. author: 'replyer',
  110. ignore: true
  111. }
  112. messageList.value.push(record)
  113. setTimeout(() => {
  114. messageScroll()
  115. })
  116. // 监听滚动事件 到顶部自动加载历史记录
  117. document.querySelector('.chat-message')?.addEventListener('scroll', (e) => {
  118. handleScroll(e, newRecordTime)
  119. })
  120. }
  121. // 消息内容发出
  122. const handleOutboundMessage = (message) => {
  123. if (!message) {
  124. return
  125. }
  126. let body = {}
  127. if (typeof message === 'string') {
  128. body = {
  129. text: message
  130. }
  131. } else if (Object.prototype.toString.call(message) === '[object Object]') {
  132. body = message
  133. }
  134. handleMessageReceived({
  135. body,
  136. date: Date.now(),
  137. author: 'sender'
  138. })
  139. }
  140. const chatArea = ref()
  141. // 实时滚动
  142. const messageScroll = () => {
  143. chatArea.value.scrollTop = chatArea.value.scrollHeight
  144. }
  145. // 模拟思考
  146. const setThinking = (bol: boolean = true) => {
  147. if (bol) {
  148. messageList.value.push({
  149. body: { component: Thinking },
  150. author: 'replyer'
  151. })
  152. } else messageList.value.pop()
  153. }
  154. const sessionId = ref('')
  155. // 发送消息
  156. const handleMessageReceived = async (message) => {
  157. // 展示用户问题
  158. await addMessage(message)
  159. setTimeout(() => {
  160. messageScroll()
  161. })
  162. // 及时来一个loading 模拟思考
  163. setThinking()
  164. emit('handleSend', message)
  165. // 移除loading
  166. // setThinking(false)
  167. // handleMessageResponse({ text })
  168. // else { // demo部分
  169. // // 无答案话术
  170. // this.handleMessageResponse({
  171. // text: '很抱歉,您问的问题暂未找到答案,请尝试其他问题'
  172. // })
  173. // }
  174. // setTimeout(() => {
  175. // messageScroll()
  176. // })
  177. }
  178. // 回答
  179. const handleMessageResponse = async (message) => {
  180. console.log(message)
  181. if (Object.prototype.toString.call(message) !== '[object Object]') {
  182. return new Error('得是object格式')
  183. }
  184. // 移除loading
  185. setThinking(false)
  186. const record = {
  187. body: { ...message },
  188. date: new Date().getTime(),
  189. author: 'replyer'
  190. }
  191. await addMessage(record)
  192. setTimeout(() => {
  193. messageScroll()
  194. })
  195. }
  196. // 开启新对话
  197. const newChat = () => {
  198. sessionId.value = ''
  199. messageList.value.push({
  200. body: { text: '已开启新对话~' },
  201. author: 'replyer',
  202. ignore: true
  203. })
  204. }
  205. const timer: any = ref(null) // 文字处理定时器
  206. const preRecord: any = ref(null) // 上一次的记录
  207. // 记录消息
  208. const addMessage = (record) => {
  209. return new Promise((resolve) => {
  210. // 第一条消息 自动加上时间
  211. // 两分钟以内连续发的消息 不显示时间 由 showTime 来判断显示与否
  212. const curList = messageList.value.filter((item) => !item.ignore)
  213. if (!curList.length || record.date - curList.slice(-1)[0].date > 2 * 60 * 1000) {
  214. record.showTime = record.date
  215. }
  216. // 消息来自用户 或者 回复内容包含组件 直接给出 message 内容
  217. // 否则纯文本类型的走 GPT动效
  218. if (record.author === 'sender' || record.body.component) {
  219. // 继续提问时 有正在进行的输出 提前结束回答
  220. if (preRecord.value) {
  221. clearInterval(timer.value)
  222. timer.value = null
  223. saveHistory(preRecord.value)
  224. }
  225. messageList.value.push(record)
  226. saveHistory(record)
  227. resolve(true)
  228. return
  229. }
  230. preRecord.value = { ...record }
  231. // **** ai的回答 纯文字的要做 GPT 文字效果 ****
  232. // 用tempText 先存一下字段
  233. const body = preRecord.value.body
  234. const { text, messageProps } = body
  235. if (record.author === 'replyer') {
  236. if (text) {
  237. body.tempText = text
  238. body.text = ''
  239. }
  240. if (messageProps?.text) {
  241. messageProps.tempText = messageProps.text
  242. messageProps.text = ''
  243. }
  244. }
  245. // 先 消息列表增加
  246. messageList.value.push(preRecord.value)
  247. // 再 处理消息效果
  248. if (preRecord.value.author === 'replyer' && (body.tempText || messageProps?.tempText)) {
  249. // 记录本次的记录 用于防止在文字演进效果时 没存入记录时 被上一条记录替换掉
  250. const tempText = body.tempText || messageProps.tempText
  251. const len = tempText.length
  252. let num = 0
  253. let string = ''
  254. timer.value = setInterval(() => {
  255. if (num >= len) {
  256. clearInterval(timer.value)
  257. timer.value = null
  258. saveHistory(preRecord.value)
  259. resolve(true)
  260. return
  261. }
  262. // 持续保持滚动最底
  263. if (num % 10 === 0) {
  264. setTimeout(() => {
  265. messageScroll()
  266. })
  267. }
  268. string = `${string}${tempText[num]}`
  269. if (body.tempText) {
  270. body.text = marked(string)
  271. } else {
  272. body.messageProps.text = marked(string)
  273. }
  274. num++
  275. }, 20)
  276. }
  277. })
  278. }
  279. // 将对话存入缓存
  280. const saveHistory = (record) => {
  281. if (!record) return
  282. // GTP对话 将对话内容还原
  283. const body = record.body
  284. const { messageProps } = body
  285. if (body.tempText) {
  286. delete body.tempText
  287. } else if (messageProps?.tempText) {
  288. delete messageProps.tempText
  289. }
  290. setTimeout(() => {
  291. messageScroll()
  292. })
  293. // 增加历史记录
  294. const historyChat = localStorage.getItem('chat')
  295. if (historyChat) {
  296. const list = JSON.parse(historyChat)
  297. list.push(record)
  298. localStorage.setItem('chat', JSON.stringify(list))
  299. } else {
  300. localStorage.setItem('chat', JSON.stringify([record]))
  301. }
  302. // 清除记录
  303. if (preRecord.value) preRecord.value = null
  304. }
  305. const historyPage = ref(1) // 分页
  306. const historyLimit = ref(10) // 每页数量
  307. // 查看历史记录
  308. const handleMoreHistory = () => {
  309. const lastPageList = historyList.value.splice(
  310. -(historyLimit.value * historyPage.value),
  311. historyLimit.value
  312. )
  313. messageList.value = [...lastPageList, ...messageList.value]
  314. historyPage.value++
  315. // 看完了
  316. if (!historyList.value.length) {
  317. showMore.value = false
  318. }
  319. nextTick(() => {
  320. // 获取查看历史记录后的信息列表
  321. const messageList = document.querySelectorAll('.chat-message-box') as NodeListOf<HTMLElement>
  322. // 获取这一批次历史记录最后一个距离文档顶部的高度
  323. const lastOffsetTop = messageList[lastPageList.length - 1]?.offsetTop || 0
  324. // 获取这一批次历史记录最后一个的高度
  325. const lastH = messageList[lastPageList.length - 1]?.clientHeight || 0
  326. // 滚动位置
  327. chatArea.value.scrollTop = lastOffsetTop + lastH
  328. })
  329. }
  330. // 滚动事件 自动加载
  331. const handleScroll = (e, time) => {
  332. if (isInit.value) return
  333. if (Date.now() - time > 1000 && isFull.value) {
  334. isFull.value = false
  335. }
  336. const step = e.target.scrollTop
  337. if (step <= 30 && showMore.value) {
  338. handleMoreHistory()
  339. }
  340. }
  341. const reset = () => {
  342. isInit.value = true
  343. messageList.value = []
  344. historyPage.value = 1
  345. historyLimit.value = 10
  346. preRecord.value = null
  347. }
  348. defineExpose({ newChat, handleMessageResponse })
  349. </script>
  350. <style lang="less">
  351. @import '../styles/variables.less';
  352. .chat-content-ai {
  353. display: flex;
  354. flex-direction: column;
  355. .is-expend & {
  356. background: transparent;
  357. }
  358. .more {
  359. text-align: center;
  360. color: @color-primary;
  361. padding: 8px 0;
  362. cursor: pointer;
  363. }
  364. .chat-message {
  365. flex: 1;
  366. overflow-y: auto;
  367. overflow-x: hidden;
  368. padding: 0 12px;
  369. margin-top: 10px;
  370. &-scroll {
  371. &.is-full {
  372. min-height: 100%;
  373. }
  374. }
  375. &-box {
  376. display: flex;
  377. position: relative;
  378. &:has(.other) {
  379. padding-top: 30px;
  380. }
  381. .other {
  382. position: absolute;
  383. top: 0;
  384. left: 0;
  385. width: 100%;
  386. font-size: 12px;
  387. text-align: center;
  388. &.time {
  389. color: #999;
  390. }
  391. }
  392. .divider {
  393. border-bottom: 1px solid #ddd;
  394. span {
  395. display: inline-block;
  396. position: relative;
  397. padding: 0 8px;
  398. top: 8px;
  399. background-color: #f4f9fd;
  400. }
  401. }
  402. .chat-message-avatar {
  403. img {
  404. width: 40px;
  405. }
  406. }
  407. .chat-message-content {
  408. width: 100%;
  409. margin-left: 4px;
  410. &__message {
  411. display: inline-block;
  412. max-width: 85%;
  413. width: auto;
  414. min-width: 20px;
  415. border-radius: 8px;
  416. font-weight: 400;
  417. line-height: 22px;
  418. padding: 12px;
  419. margin-bottom: 20px;
  420. position: relative;
  421. word-break: break-all;
  422. .message-text {
  423. > *:first-child {
  424. margin-top: 0;
  425. }
  426. > *:last-child {
  427. margin-bottom: 0;
  428. }
  429. }
  430. }
  431. }
  432. }
  433. .message-sender {
  434. justify-content: flex-end;
  435. .chat-message-avatar {
  436. order: 2;
  437. }
  438. .chat-message-content {
  439. order: 1;
  440. text-align: right;
  441. margin-right: 4px;
  442. }
  443. .chat-message-content__message {
  444. background: @color-primary;
  445. color: #ffffff;
  446. text-align: left;
  447. // 发送文件类型时 白底
  448. &:has(.white-bg) {
  449. background-color: #fff;
  450. color: #333;
  451. }
  452. }
  453. }
  454. .message-replyer {
  455. color: #333;
  456. text-align: left;
  457. .chat-message-content__message {
  458. background: #fff;
  459. color: #333;
  460. width: 96%;
  461. max-width: 96%;
  462. }
  463. }
  464. }
  465. }
  466. </style>