index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  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. <el-text class="left-text">
  101. {{item.content}}
  102. </el-text>
  103. </div>
  104. <div class="left-btns">
  105. <div class="btn-cus" @click="noCopy(item.content)">
  106. <img class="btn-image" src="@/assets/ai/copy.svg"/>
  107. <el-text class="btn-cus-text">复制</el-text>
  108. </div>
  109. <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
  110. <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
  111. <el-text class="btn-cus-text">删除</el-text>
  112. </div>
  113. </div>
  114. </div>
  115. </div>
  116. <!-- 靠右 message -->
  117. <div class="right-message message-item" v-if="item.type === 'user'">
  118. <div class="avatar">
  119. <el-avatar
  120. src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png"
  121. />
  122. </div>
  123. <div class="message">
  124. <div>
  125. <el-text class="time">{{formatDate(item.createTime)}}</el-text>
  126. </div>
  127. <div class="right-text-container">
  128. <el-text class="right-text">
  129. {{item.content}}
  130. </el-text>
  131. </div>
  132. <div class="right-btns">
  133. <div class="btn-cus" @click="noCopy(item.content)">
  134. <img class="btn-image" src="@/assets/ai/copy.svg"/>
  135. <el-text class="btn-cus-text">复制</el-text>
  136. </div>
  137. <div class="btn-cus" style="margin-left: 20px;" @click="onDelete(item.id)">
  138. <img class="btn-image" src="@/assets/ai/delete.svg" style="height: 17px;"/>
  139. <el-text class="btn-cus-text">删除</el-text>
  140. </div>
  141. </div>
  142. </div>
  143. </div>
  144. </div>
  145. </div>
  146. </el-main>
  147. <el-footer class="footer-container">
  148. <textarea class="prompt-input" v-model="prompt" placeholder="问我任何问题...(Shift+Enter 换行,按下 Enter 发送)"></textarea>
  149. <div class="prompt-btns">
  150. <el-switch/>
  151. <el-button type="primary" size="default" @click="onSend()">发送</el-button>
  152. </div>
  153. </el-footer>
  154. </el-container>
  155. </el-container>
  156. </template>
  157. <script setup lang="ts">
  158. import {ChatMessageApi, ChatMessageSendVO, ChatMessageVO} from "@/api/ai/chat/message";
  159. import {formatDate} from "@/utils/formatTime";
  160. import {useClipboard} from '@vueuse/core'
  161. const searchName = ref('') // 查询的内容
  162. const conversationId = ref('1781604279872581648') // 对话id
  163. const conversationInProgress = ref<Boolean>() // 对话进行中
  164. const prompt = ref<string>() // prompt
  165. const promptRes = ref<string>() // prompt res
  166. const changeConversation = (conversation) => {
  167. console.log(conversation)
  168. conversationId.value = conversation.id
  169. // TODO 芋艿:待实现
  170. }
  171. const updateConversationTitle = (conversation) => {
  172. console.log(conversation)
  173. // TODO 芋艿:待实现
  174. }
  175. const deleteConversationTitle = (conversation) => {
  176. console.log(conversation)
  177. // TODO 芋艿:待实现
  178. }
  179. const searchConversation = () => {
  180. // TODO 芋艿:待实现
  181. }
  182. /** send */
  183. const onSend = async () => {
  184. const requestParams = {
  185. conversationId: conversationId.value,
  186. content: prompt.value,
  187. } as unknown as ChatMessageSendVO
  188. // 添加 message
  189. const userMessage = await ChatMessageApi.add(requestParams) as unknown as ChatMessageVO;
  190. list.value.push(userMessage)
  191. // 滚动到住下面
  192. scrollToBottom();
  193. //
  194. await doSendStream(userMessage)
  195. }
  196. const doSendStream = async (userMessage: ChatMessageVO) => {
  197. // 创建AbortController实例,以便中止请求
  198. const ctrl = new AbortController()
  199. // 发送 event stream
  200. let isFirstMessage = true
  201. ChatMessageApi.sendStream(userMessage.id, ctrl,(message) => {
  202. console.log('message', message)
  203. const data = JSON.parse(message.data) as unknown as ChatMessageVO
  204. // 如果没有内容结束链接
  205. if (data.content === '') {
  206. ctrl.abort()
  207. }
  208. // 首次返回需要添加一个 message 到页面,后面的都是更新
  209. if (isFirstMessage) {
  210. isFirstMessage = false;
  211. list.value.push(data)
  212. } else {
  213. const lastMessage = list.value[list.value.length - 1];
  214. lastMessage.content = lastMessage.content + data.content
  215. list.value[list.value - 1] = lastMessage
  216. }
  217. // 滚动到最下面
  218. scrollToBottom();
  219. }, (error) => {
  220. console.log('error', error)
  221. }, () => {
  222. console.log('close')
  223. })
  224. // // 创建一个正在进行中的message
  225. // const chatMessage = {
  226. // id: null, // 编号
  227. // conversationId: conversationId.value, // 会话编号
  228. // type: 'system', // 消息类型
  229. // userId: null, // 用户编号
  230. // roleId: null, // 角色编号
  231. // model: null, // 模型标志
  232. // modelId: null, // 模型编号
  233. // content: '加载中...', // 聊天内容
  234. // tokens: null, // 消耗 Token 数量
  235. // createTime: new Date(), // 创建时间
  236. // } as unknown as ChatMessageVO
  237. // list.value.push(chatMessage)
  238. // // 滚动到最下面
  239. // scrollToBottom();
  240. }
  241. /** Prompt */
  242. const onPromptInput = async (e) => {
  243. console.log(e.data)
  244. // prompt.value = e.data
  245. }
  246. // 初始化 copy 到粘贴板
  247. const { copy, isSupported } = useClipboard();
  248. /** chat message 列表 */
  249. defineOptions({ name: 'chatMessageList' })
  250. const list = ref<ChatMessageVO[]>([]) // 列表的数据
  251. // 对话id TODO @范 先写死
  252. const content = '苹果是什么颜色?'
  253. /** 查询列表 */
  254. const messageList = async () => {
  255. try {
  256. // 获取列表数据
  257. const res = await ChatMessageApi.messageList(conversationId.value)
  258. list.value = res;
  259. // 滚动到最下面
  260. scrollToBottom();
  261. } finally {
  262. }
  263. }
  264. // ref
  265. const messageContainer: any = ref(null);
  266. const isScrolling = ref(false)//用于判断用户是否在滚动
  267. function scrollToBottom() {
  268. nextTick(() => {
  269. //注意要使用nexttick以免获取不到dom
  270. console.log('isScrolling.value', isScrolling.value)
  271. if (!isScrolling.value) {
  272. messageContainer.value.scrollTop = messageContainer.value.scrollHeight - messageContainer.value.offsetHeight
  273. }
  274. })
  275. }
  276. function handleScroll() {
  277. const scrollContainer = messageContainer.value
  278. const scrollTop = scrollContainer.scrollTop
  279. const scrollHeight = scrollContainer.scrollHeight
  280. const offsetHeight = scrollContainer.offsetHeight
  281. if (scrollTop + offsetHeight < scrollHeight) {
  282. // 用户开始滚动并在最底部之上,取消保持在最底部的效果
  283. isScrolling.value = true
  284. } else {
  285. // 用户停止滚动并滚动到最底部,开启保持到最底部的效果
  286. isScrolling.value = false
  287. }
  288. }
  289. function noCopy(content) {
  290. copy(content)
  291. ElMessage({
  292. message: '复制成功!',
  293. type: 'success',
  294. })
  295. }
  296. const onDelete = async (id) => {
  297. // 删除 message
  298. await ChatMessageApi.delete(id)
  299. ElMessage({
  300. message: '删除成功!',
  301. type: 'success',
  302. })
  303. // 重新获取 message 列表
  304. await messageList();
  305. }
  306. /** 初始化 **/
  307. onMounted(async () => {
  308. // 获取列表数据
  309. messageList();
  310. // scrollToBottom();
  311. // await nextTick
  312. // 监听滚动事件,判断用户滚动状态
  313. messageContainer.value.addEventListener('scroll', handleScroll)
  314. })
  315. </script>
  316. <style lang="scss" scoped>
  317. .ai-layout {
  318. // TODO @范 这里height不能 100% 先这样临时处理
  319. position: absolute;
  320. flex: 1;
  321. top: 0;
  322. left: 0;
  323. height: 100%;
  324. width: 100%;
  325. //padding: 15px 15px;
  326. }
  327. .conversation-container {
  328. position: relative;
  329. display: flex;
  330. flex-direction: column;
  331. justify-content: space-between;
  332. padding: 0 10px;
  333. padding-top: 10px;
  334. .btn-new-conversation {
  335. padding: 18px 0;
  336. }
  337. .search-input {
  338. margin-top: 20px;
  339. }
  340. .conversation-list {
  341. margin-top: 20px;
  342. .conversation {
  343. display: flex;
  344. flex-direction: row;
  345. justify-content: space-between;
  346. flex: 1;
  347. padding: 0 5px;
  348. margin-top: 10px;
  349. cursor: pointer;
  350. border-radius: 5px;
  351. align-items: center;
  352. line-height: 30px;
  353. &.active {
  354. background-color: #e6e6e6;
  355. .button {
  356. display: inline-block;
  357. }
  358. }
  359. .title-wrapper {
  360. display: flex;
  361. flex-direction: row;
  362. align-items: center;
  363. }
  364. .title {
  365. padding: 5px 10px;
  366. max-width: 220px;
  367. font-size: 14px;
  368. overflow: hidden;
  369. white-space: nowrap;
  370. text-overflow: ellipsis;
  371. }
  372. .avatar {
  373. width: 28px;
  374. height: 28px;
  375. display: flex;
  376. flex-direction: row;
  377. justify-items: center;
  378. }
  379. // 对话编辑、删除
  380. .button-wrapper {
  381. right: 2px;
  382. display: flex;
  383. flex-direction: row;
  384. justify-items: center;
  385. color: #606266;
  386. .el-icon {
  387. margin-right: 5px;
  388. }
  389. }
  390. }
  391. }
  392. // 角色仓库、清空未设置对话
  393. .tool-box {
  394. line-height: 35px;
  395. display: flex;
  396. justify-content: space-between;
  397. align-items: center;
  398. color: var(--el-text-color);
  399. > div {
  400. display: flex;
  401. align-items: center;
  402. color: #606266;
  403. padding: 0;
  404. margin: 0;
  405. > span {
  406. margin-left: 5px;
  407. }
  408. }
  409. }
  410. }
  411. // 头部
  412. .detail-container {
  413. background: #ffffff;
  414. .header {
  415. display: flex;
  416. flex-direction: row;
  417. align-items: center;
  418. justify-content: space-between;
  419. background: #fbfbfb;
  420. box-shadow: 0 0 0 0 #dcdfe6;
  421. .title {
  422. font-size: 18px;
  423. font-weight: bold;
  424. }
  425. }
  426. }
  427. // main 容器
  428. .main-container {
  429. margin: 0;
  430. padding: 0;
  431. position: relative;
  432. }
  433. .message-container {
  434. position: absolute;
  435. top: 0;
  436. bottom: 0;
  437. overflow-y: scroll;
  438. padding: 0 15px;
  439. }
  440. // 中间
  441. .chat-list {
  442. display: flex;
  443. flex-direction: column;
  444. overflow-y: hidden;
  445. .message-item {
  446. margin-top: 50px;
  447. }
  448. .left-message {
  449. display: flex;
  450. flex-direction: row;
  451. }
  452. .right-message {
  453. display: flex;
  454. flex-direction: row-reverse;
  455. justify-content: flex-start;
  456. }
  457. .avatar {
  458. //height: 170px;
  459. //width: 170px;
  460. }
  461. .message {
  462. display: flex;
  463. flex-direction: column;
  464. text-align: left;
  465. margin: 0 15px;
  466. .time {
  467. text-align: left;
  468. line-height: 30px;
  469. }
  470. .left-text-container {
  471. display: flex;
  472. flex-direction: column;
  473. overflow-wrap: break-word;
  474. background-color: #e4e4e4;
  475. box-shadow: 0 0 0 1px #e4e4e4;
  476. border-radius: 10px;
  477. padding: 10px 10px 5px 10px;
  478. .left-text {
  479. color: #393939;
  480. }
  481. }
  482. .right-text-container {
  483. display: flex;
  484. flex-direction: column;
  485. overflow-wrap: break-word;
  486. background-color: #267fff;
  487. color: #FFF;
  488. box-shadow: 0 0 0 1px #267fff;
  489. border-radius: 10px;
  490. padding: 10px;
  491. .right-text {
  492. color: #FFF;
  493. }
  494. }
  495. .left-btns, .right-btns {
  496. display: flex;
  497. flex-direction: row;
  498. margin-top: 8px;
  499. }
  500. }
  501. // 复制、删除按钮
  502. .btn-cus {
  503. display: flex;
  504. background-color: transparent;
  505. align-items: center;
  506. .btn-image {
  507. height: 20px;
  508. margin-right: 5px;
  509. }
  510. .btn-cus-text {
  511. color: #757575;
  512. }
  513. }
  514. .btn-cus:hover {
  515. cursor: pointer;
  516. }
  517. .btn-cus:focus {
  518. background-color: #8c939d;
  519. }
  520. }
  521. // 底部
  522. .footer-container {
  523. display: flex;
  524. flex-direction: column;
  525. height: auto;
  526. border: 1px solid #e3e3e3;
  527. border-radius: 10px;
  528. margin: 20px 20px;
  529. padding: 9px 10px;
  530. .prompt-input {
  531. height: 80px;
  532. //box-shadow: none;
  533. border: none;
  534. box-sizing: border-box;
  535. resize: none;
  536. padding: 0px 2px;
  537. //padding: 5px 5px;
  538. overflow: hidden;
  539. }
  540. .prompt-input:focus {
  541. outline: none;
  542. }
  543. .prompt-btns {
  544. display: flex;
  545. justify-content: space-between;
  546. padding-bottom: 0px;
  547. padding-top: 5px;
  548. }
  549. }
  550. </style>