index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. <template>
  2. <el-container class="ai-layout">
  3. <!-- 左侧:会话列表 -->
  4. <Conversation @onConversationClick="handleConversationClick"
  5. @onConversationClear="handlerConversationClear" />
  6. <!-- 右侧:会话详情 -->
  7. <el-container class="detail-container">
  8. <!-- 右顶部 TODO 芋艿:右对齐 -->
  9. <el-header class="header">
  10. <div class="title">
  11. {{ activeConversation?.title }}
  12. </div>
  13. <div>
  14. <!-- TODO @fan:样式改下;这里我已经改成点击后,弹出了 -->
  15. <el-button type="primary" @click="openChatConversationUpdateForm">
  16. <span v-html="activeConversation?.modelName"></span>
  17. <Icon icon="ep:setting" style="margin-left: 10px"/>
  18. </el-button>
  19. <el-button>
  20. <Icon icon="ep:user"/>
  21. </el-button>
  22. <el-button>
  23. <Icon icon="ep:download"/>
  24. </el-button>
  25. <el-button>
  26. <Icon icon="ep:arrow-up"/>
  27. </el-button>
  28. </div>
  29. </el-header>
  30. <!-- main -->
  31. <el-main class="main-container">
  32. <div class="message-container" ref="messageContainer">
  33. <div class="chat-list" v-for="(item, index) in list" :key="index">
  34. <!-- 靠左 message -->
  35. <!-- TODO 芋艿:类型判断 -->
  36. <div class="left-message message-item" v-if="item.type === 'system'">
  37. <div class="avatar">
  38. <el-avatar
  39. src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
  40. />
  41. </div>
  42. <div class="message">
  43. <div>
  44. <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
  45. </div>
  46. <div class="left-text-container" ref="markdownViewRef">
  47. <MarkdownView class="left-text" :content="item.content" />
  48. </div>
  49. <div class="left-btns">
  50. <div class="btn-cus" @click="noCopy(item.content)">
  51. <img class="btn-image" src="../../../assets/ai/copy.svg"/>
  52. <el-text class="btn-cus-text">复制</el-text>
  53. </div>
  54. <div class="btn-cus" style="margin-left: 20px" @click="onDelete(item.id)">
  55. <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px"/>
  56. <el-text class="btn-cus-text">删除</el-text>
  57. </div>
  58. </div>
  59. </div>
  60. </div>
  61. <!-- 靠右 message -->
  62. <div class="right-message message-item" v-if="item.type === 'user'">
  63. <div class="avatar">
  64. <el-avatar
  65. src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
  66. />
  67. </div>
  68. <div class="message">
  69. <div>
  70. <el-text class="time">{{ formatDate(item.createTime) }}</el-text>
  71. </div>
  72. <div class="right-text-container">
  73. <div class="right-text">{{ item.content }}</div>
  74. </div>
  75. <div class="right-btns">
  76. <div class="btn-cus" @click="noCopy(item.content)">
  77. <img class="btn-image" src="@/assets/ai/copy.svg"/>
  78. <el-text class="btn-cus-text">复制</el-text>
  79. </div>
  80. <div class="btn-cus" style="margin-left: 20px" @click="onDelete(item.id)">
  81. <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px"/>
  82. <el-text class="btn-cus-text">删除</el-text>
  83. </div>
  84. </div>
  85. </div>
  86. </div>
  87. </div>
  88. </div>
  89. </el-main>
  90. <el-footer class="footer-container">
  91. <form @submit.prevent="onSend" class="prompt-from">
  92. <textarea
  93. class="prompt-input"
  94. v-model="prompt"
  95. @keyup.enter="onSend"
  96. @input="onPromptInput"
  97. @compositionstart="onCompositionstart"
  98. @compositionend="onCompositionend"
  99. placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"
  100. ></textarea>
  101. <div class="prompt-btns">
  102. <el-switch/>
  103. <el-button
  104. type="primary"
  105. size="default"
  106. @click="onSend()"
  107. :loading="conversationInProgress"
  108. v-if="conversationInProgress == false"
  109. >
  110. {{ conversationInProgress ? '进行中' : '发送' }}
  111. </el-button>
  112. <el-button
  113. type="danger"
  114. size="default"
  115. @click="stopStream()"
  116. v-if="conversationInProgress == true"
  117. >
  118. 停止
  119. </el-button>
  120. </div>
  121. </form>
  122. </el-footer>
  123. </el-container>
  124. <!-- ========= 额外组件 ========== -->
  125. <!-- 更新对话 form -->
  126. <ChatConversationUpdateForm
  127. ref="chatConversationUpdateFormRef"
  128. @success="handlerTitleSuccess"
  129. />
  130. </el-container>
  131. </template>
  132. <script setup lang="ts">
  133. import MarkdownView from '@/components/MarkdownView/index.vue'
  134. import Conversation from './Conversation.vue'
  135. import {ChatMessageApi, ChatMessageVO} from '@/api/ai/chat/message'
  136. import {ChatConversationVO} from '@/api/ai/chat/conversation'
  137. import {formatDate} from '@/utils/formatTime'
  138. import {useClipboard} from '@vueuse/core'
  139. import ChatConversationUpdateForm from "@/views/ai/chat/components/ChatConversationUpdateForm.vue";
  140. const route = useRoute() // 路由
  141. const message = useMessage() // 消息弹窗
  142. const {copy} = useClipboard() // 初始化 copy 到粘贴板
  143. // ref 属性定义
  144. const activeConversationId = ref<number | null>(null) // 选中的对话编号
  145. const activeConversation = ref<ChatConversationVO | null>(null) // 选中的 Conversation
  146. const conversationInProgress = ref(false) // 对话进行中
  147. const conversationInAbortController = ref<any>() // 对话进行中 abort 控制器(控制 stream 对话)
  148. const inputTimeout = ref<any>() // 处理输入中回车的定时器
  149. const prompt = ref<string>() // prompt
  150. // 判断 消息列表 滚动的位置(用于判断是否需要滚动到消息最下方)
  151. const messageContainer: any = ref(null)
  152. const isScrolling = ref(false) //用于判断用户是否在滚动
  153. const isComposing = ref(false) // 判断用户是否在输入
  154. /** chat message 列表 */
  155. const list = ref<ChatMessageVO[]>([]) // 列表的数据
  156. // ============ 处理对话滚动 ==============
  157. function scrollToBottom() {
  158. nextTick(() => {
  159. //注意要使用nexttick以免获取不到dom
  160. console.log('isScrolling.value', isScrolling.value)
  161. if (!isScrolling.value) {
  162. messageContainer.value.scrollTop =
  163. messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
  164. }
  165. })
  166. }
  167. function handleScroll() {
  168. const scrollContainer = messageContainer.value
  169. const scrollTop = scrollContainer.scrollTop
  170. const scrollHeight = scrollContainer.scrollHeight
  171. const offsetHeight = scrollContainer.offsetHeight
  172. if ((scrollTop + offsetHeight) < (scrollHeight - 50)) {
  173. // 用户开始滚动并在最底部之上,取消保持在最底部的效果
  174. isScrolling.value = true
  175. } else {
  176. // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
  177. isScrolling.value = false
  178. }
  179. }
  180. // ============= 处理聊天输入回车发送 =============
  181. const onCompositionstart = () => {
  182. isComposing.value = true
  183. }
  184. const onCompositionend = () => {
  185. // console.log('输入结束...')
  186. setTimeout(() => {
  187. isComposing.value = false
  188. }, 200)
  189. }
  190. const onPromptInput = (event) => {
  191. // 非输入法 输入设置为 true
  192. if (!isComposing.value) {
  193. // 回车 event data 是 null
  194. if (event.data == null) {
  195. return
  196. }
  197. isComposing.value = true
  198. }
  199. // 清理定时器
  200. if (inputTimeout.value) {
  201. clearTimeout(inputTimeout.value)
  202. }
  203. // 重置定时器
  204. inputTimeout.value = setTimeout(() => {
  205. isComposing.value = false
  206. }, 400)
  207. }
  208. // ============== 对话消息相关 =================
  209. /**
  210. * 发送消息
  211. */
  212. const onSend = async () => {
  213. // 判断用户是否在输入
  214. if (isComposing.value) {
  215. return
  216. }
  217. // 进行中不允许发送
  218. if (conversationInProgress.value) {
  219. return
  220. }
  221. const content = prompt.value?.trim() + ''
  222. if (content.length < 2) {
  223. ElMessage({
  224. message: '请输入内容!',
  225. type: 'error'
  226. })
  227. return
  228. }
  229. // TODO 芋艿:这块交互要在优化;应该是先插入到 UI 界面,里面会有当前的消息,和正在思考中;之后发起请求;
  230. // 清空输入框
  231. prompt.value = ''
  232. const userMessage = {
  233. conversationId: activeConversationId.value,
  234. content: content
  235. } as ChatMessageVO
  236. // list.value.push(userMessage)
  237. // 滚动到住下面
  238. scrollToBottom()
  239. // stream
  240. await doSendStream(userMessage)
  241. }
  242. const doSendStream = async (userMessage: ChatMessageVO) => {
  243. // 创建AbortController实例,以便中止请求
  244. conversationInAbortController.value = new AbortController()
  245. // 标记对话进行中
  246. conversationInProgress.value = true
  247. try {
  248. // 发送 event stream
  249. let isFirstMessage = true
  250. let content = ''
  251. ChatMessageApi.sendStream(
  252. userMessage.conversationId, // TODO 芋艿:这里可能要在优化;
  253. userMessage.content,
  254. conversationInAbortController.value,
  255. (message) => {
  256. console.log('message', message)
  257. const data = JSON.parse(message.data) // TODO 芋艿:类型处理;
  258. // debugger
  259. // 如果没有内容结束链接
  260. if (data.receive.content === '') {
  261. // 标记对话结束
  262. conversationInProgress.value = false
  263. // 结束 stream 对话
  264. conversationInAbortController.value.abort()
  265. }
  266. // 首次返回需要添加一个 message 到页面,后面的都是更新
  267. if (isFirstMessage) {
  268. isFirstMessage = false
  269. // debugger
  270. list.value.push(data.send)
  271. list.value.push(data.receive)
  272. } else {
  273. // debugger
  274. content = content + data.receive.content
  275. const lastMessage = list.value[list.value.length - 1]
  276. lastMessage.content = content
  277. list.value[list.value - 1] = lastMessage
  278. }
  279. // 滚动到最下面
  280. scrollToBottom()
  281. },
  282. (error) => {
  283. console.log('error', error)
  284. // 标记对话结束
  285. conversationInProgress.value = false
  286. // 结束 stream 对话
  287. conversationInAbortController.value.abort()
  288. },
  289. () => {
  290. console.log('close')
  291. // 标记对话结束
  292. conversationInProgress.value = false
  293. // 结束 stream 对话
  294. conversationInAbortController.value.abort()
  295. }
  296. )
  297. } finally {
  298. }
  299. }
  300. const stopStream = async () => {
  301. // tip:如果 stream 进行中的 message,就需要调用 controller 结束
  302. if (conversationInAbortController.value) {
  303. conversationInAbortController.value.abort()
  304. }
  305. // 设置为 false
  306. conversationInProgress.value = false
  307. }
  308. // ============== message 数据 =================
  309. /**
  310. * 获取 - message 列表
  311. */
  312. const getMessageList = async () => {
  313. try {
  314. if (activeConversationId.value === null) {
  315. return
  316. }
  317. // 获取列表数据
  318. list.value = await ChatMessageApi.messageList(activeConversationId.value)
  319. // 滚动到最下面
  320. await nextTick(() => {
  321. scrollToBottom()
  322. })
  323. } finally {
  324. }
  325. }
  326. function noCopy(content) {
  327. copy(content)
  328. ElMessage({
  329. message: '复制成功!',
  330. type: 'success'
  331. })
  332. }
  333. const onDelete = async (id) => {
  334. // 删除 message
  335. await ChatMessageApi.delete(id)
  336. ElMessage({
  337. message: '删除成功!',
  338. type: 'success'
  339. })
  340. // tip:如果 stream 进行中的 message,就需要调用 controller 结束
  341. await stopStream()
  342. // 重新获取 message 列表
  343. await getMessageList()
  344. }
  345. /** 修改聊天会话 */
  346. const chatConversationUpdateFormRef = ref()
  347. const openChatConversationUpdateForm = async () => {
  348. chatConversationUpdateFormRef.value.open(activeConversationId.value)
  349. }
  350. /**
  351. * 对话 - 标题修改成功
  352. */
  353. const handlerTitleSuccess = async () => {
  354. // TODO 需要刷新 对话列表
  355. }
  356. /**
  357. * 对话 - 点击
  358. */
  359. const handleConversationClick = async (conversation: ChatConversationVO) => {
  360. // 更新选中的对话 id
  361. activeConversationId.value = conversation.id
  362. // 刷新 message 列表
  363. await getMessageList()
  364. }
  365. /**
  366. * 对话 - 清理全部对话
  367. */
  368. const handlerConversationClear = async ()=> {
  369. activeConversationId.value = null
  370. activeConversation.value = null
  371. list.value = []
  372. }
  373. /** 初始化 **/
  374. onMounted(async () => {
  375. // 设置当前对话 TODO 角色仓库过来的,自带 conversationId 需要选中
  376. // if (route.query.conversationId) {
  377. // conversationId.value = route.query.conversationId as number
  378. // }
  379. // 获得聊天会话列表
  380. // await getChatConversationList()
  381. // 获取对话信息
  382. // await getConversation(conversationId.value)
  383. // 获取列表数据
  384. await getMessageList()
  385. // scrollToBottom();
  386. // await nextTick
  387. // 监听滚动事件,判断用户滚动状态
  388. messageContainer.value.addEventListener('scroll', handleScroll)
  389. // 添加 copy 监听
  390. messageContainer.value.addEventListener('click', (e: any) => {
  391. console.log(e)
  392. if (e.target.id === 'copy') {
  393. copy(e.target?.dataset?.copy)
  394. ElMessage({
  395. message: '复制成功!',
  396. type: 'success'
  397. })
  398. }
  399. })
  400. })
  401. </script>
  402. <style lang="scss" scoped>
  403. .ai-layout {
  404. // TODO @范 这里height不能 100% 先这样临时处理
  405. position: absolute;
  406. flex: 1;
  407. top: 0;
  408. left: 0;
  409. height: 100%;
  410. width: 100%;
  411. //padding: 15px 15px;
  412. }
  413. .conversation-container {
  414. position: relative;
  415. display: flex;
  416. flex-direction: column;
  417. justify-content: space-between;
  418. padding: 0 10px;
  419. padding-top: 10px;
  420. .btn-new-conversation {
  421. padding: 18px 0;
  422. }
  423. .search-input {
  424. margin-top: 20px;
  425. }
  426. .conversation-list {
  427. margin-top: 20px;
  428. .conversation {
  429. display: flex;
  430. flex-direction: row;
  431. justify-content: space-between;
  432. flex: 1;
  433. padding: 0 5px;
  434. margin-top: 10px;
  435. cursor: pointer;
  436. border-radius: 5px;
  437. align-items: center;
  438. line-height: 30px;
  439. &.active {
  440. background-color: #e6e6e6;
  441. .button {
  442. display: inline-block;
  443. }
  444. }
  445. .title-wrapper {
  446. display: flex;
  447. flex-direction: row;
  448. align-items: center;
  449. }
  450. .title {
  451. padding: 5px 10px;
  452. max-width: 220px;
  453. font-size: 14px;
  454. overflow: hidden;
  455. white-space: nowrap;
  456. text-overflow: ellipsis;
  457. }
  458. .avatar {
  459. width: 28px;
  460. height: 28px;
  461. display: flex;
  462. flex-direction: row;
  463. justify-items: center;
  464. }
  465. // 对话编辑、删除
  466. .button-wrapper {
  467. right: 2px;
  468. display: flex;
  469. flex-direction: row;
  470. justify-items: center;
  471. color: #606266;
  472. .el-icon {
  473. margin-right: 5px;
  474. }
  475. }
  476. }
  477. }
  478. // 角色仓库、清空未设置对话
  479. .tool-box {
  480. line-height: 35px;
  481. display: flex;
  482. justify-content: space-between;
  483. align-items: center;
  484. color: var(--el-text-color);
  485. > div {
  486. display: flex;
  487. align-items: center;
  488. color: #606266;
  489. padding: 0;
  490. margin: 0;
  491. cursor: pointer;
  492. > span {
  493. margin-left: 5px;
  494. }
  495. }
  496. }
  497. }
  498. // 头部
  499. .detail-container {
  500. background: #ffffff;
  501. .header {
  502. display: flex;
  503. flex-direction: row;
  504. align-items: center;
  505. justify-content: space-between;
  506. background: #fbfbfb;
  507. box-shadow: 0 0 0 0 #dcdfe6;
  508. .title {
  509. font-size: 18px;
  510. font-weight: bold;
  511. }
  512. }
  513. }
  514. // main 容器
  515. .main-container {
  516. margin: 0;
  517. padding: 0;
  518. position: relative;
  519. }
  520. .message-container {
  521. position: absolute;
  522. top: 0;
  523. bottom: 0;
  524. left: 0;
  525. right: 0;
  526. //width: 100%;
  527. //height: 100%;
  528. overflow-y: scroll;
  529. padding: 0 15px;
  530. }
  531. // 中间
  532. .chat-list {
  533. display: flex;
  534. flex-direction: column;
  535. overflow-y: hidden;
  536. .message-item {
  537. margin-top: 50px;
  538. }
  539. .left-message {
  540. display: flex;
  541. flex-direction: row;
  542. }
  543. .right-message {
  544. display: flex;
  545. flex-direction: row-reverse;
  546. justify-content: flex-start;
  547. }
  548. .avatar {
  549. //height: 170px;
  550. //width: 170px;
  551. }
  552. .message {
  553. display: flex;
  554. flex-direction: column;
  555. text-align: left;
  556. margin: 0 15px;
  557. .time {
  558. text-align: left;
  559. line-height: 30px;
  560. }
  561. .left-text-container {
  562. display: flex;
  563. flex-direction: column;
  564. overflow-wrap: break-word;
  565. background-color: rgba(228, 228, 228, 0.8);
  566. box-shadow: 0 0 0 1px rgba(228, 228, 228, 0.8);
  567. border-radius: 10px;
  568. padding: 10px 10px 5px 10px;
  569. .left-text {
  570. color: #393939;
  571. font-size: 0.95rem;
  572. }
  573. }
  574. .right-text-container {
  575. display: flex;
  576. flex-direction: row-reverse;
  577. .right-text {
  578. font-size: 0.95rem;
  579. color: #fff;
  580. display: inline;
  581. background-color: #267fff;
  582. color: #fff;
  583. box-shadow: 0 0 0 1px #267fff;
  584. border-radius: 10px;
  585. padding: 10px;
  586. width: auto;
  587. overflow-wrap: break-word;
  588. }
  589. }
  590. .left-btns,
  591. .right-btns {
  592. display: flex;
  593. flex-direction: row;
  594. margin-top: 8px;
  595. }
  596. }
  597. // 复制、删除按钮
  598. .btn-cus {
  599. display: flex;
  600. background-color: transparent;
  601. align-items: center;
  602. .btn-image {
  603. height: 20px;
  604. margin-right: 5px;
  605. }
  606. .btn-cus-text {
  607. color: #757575;
  608. }
  609. }
  610. .btn-cus:hover {
  611. cursor: pointer;
  612. }
  613. .btn-cus:focus {
  614. background-color: #8c939d;
  615. }
  616. }
  617. // 底部
  618. .footer-container {
  619. display: flex;
  620. flex-direction: column;
  621. height: auto;
  622. margin: 0;
  623. padding: 0;
  624. .prompt-from {
  625. display: flex;
  626. flex-direction: column;
  627. height: auto;
  628. border: 1px solid #e3e3e3;
  629. border-radius: 10px;
  630. margin: 20px 20px;
  631. padding: 9px 10px;
  632. }
  633. .prompt-input {
  634. height: 80px;
  635. //box-shadow: none;
  636. border: none;
  637. box-sizing: border-box;
  638. resize: none;
  639. padding: 0px 2px;
  640. //padding: 5px 5px;
  641. overflow: hidden;
  642. }
  643. .prompt-input:focus {
  644. outline: none;
  645. }
  646. .prompt-btns {
  647. display: flex;
  648. justify-content: space-between;
  649. padding-bottom: 0px;
  650. padding-top: 5px;
  651. }
  652. }
  653. </style>