index.vue 17 KB

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