index.vue 18 KB

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