index.vue 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <template>
  2. <a-drawer :visible="props.visible" class="drawer" width="570px" @cancel="closeAI" placement="left" :footer="false">
  3. <template #title>
  4. <div class="aiTitle">
  5. <img alt="AI" src="@/assets/ai.svg" height="25">
  6. <span>AI助手</span>
  7. <a-button size="small" class="button" @click="deleteMessages">清空记录</a-button>
  8. </div>
  9. </template>
  10. <div class="container">
  11. <div class="messages" ref="messagesContainer">
  12. <a-spin :loading="messagesLoading">
  13. <div v-for="(item, index) in messages">
  14. <div class="time"
  15. v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
  16. stramptoTime(item.time) }}</div>
  17. <div :class="['message', item.type === 'user' ? 'right' : 'left']">
  18. <a-avatar class="avatar">
  19. <img alt="avatar" src="@/assets/img/avatar/assistant.png" v-if="item.type !== 'user'" />
  20. <img alt="avatar" :src="user.avatar" v-else-if="item.type === 'user'" />
  21. </a-avatar>
  22. <a-dropdown trigger="contextMenu" alignPoint :style="{ display: 'block', zIndex: 99 }">
  23. <div :class="['content', item.type === 'user' ? 'user' : 'ai']"
  24. v-html="renderMarkdown(item.content)">
  25. </div>
  26. <template #content>
  27. <a-doption>
  28. <icon-play-arrow /> 打开仓库
  29. </a-doption>
  30. </template>
  31. </a-dropdown>
  32. </div>
  33. </div>
  34. </a-spin>
  35. </div>
  36. <div class="inputBox">
  37. <a-textarea placeholder="您的专属AI助手~" :max-length="300" :auto-size="{
  38. minRows: 1,
  39. maxRows: 6
  40. }" allow-clear v-model="input" />
  41. <a-button type="primary" :loading="send" @click="sendMessage">发送</a-button>
  42. </div>
  43. </div>
  44. </a-drawer>
  45. </template>
  46. <script setup>
  47. import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
  48. import { Message, Modal } from '@arco-design/web-vue'
  49. import { eventBus } from '@/utils/eventBus'
  50. import { AIChat, GetAIChatMessages, GetAIChatMessage, DeleteAIChatMessages } from '@/api/ai'
  51. import { useUserStore } from '@/store/modules/user'
  52. import MarkdownIt from 'markdown-it'
  53. const md = new MarkdownIt()
  54. const renderMarkdown = (text) => {
  55. return md.render(text || '')
  56. }
  57. const props = defineProps({
  58. visible: {
  59. type: Boolean,
  60. default: false
  61. }
  62. })
  63. const user = ref('')
  64. const input = ref('')
  65. const messagesLoading = ref(false)
  66. const send = ref(false)
  67. const messages = ref([])
  68. const msgid = ref('')
  69. const newIndex = ref()
  70. const messagesContainer = ref(null)
  71. const scrollToBottom = () => {
  72. if (messagesContainer.value) {
  73. // 等 DOM 渲染完成再滚动
  74. nextTick(() => {
  75. messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
  76. })
  77. }
  78. }
  79. const getAIChatMessages = async () => {
  80. try {
  81. messagesLoading.value = true
  82. const res = await GetAIChatMessages()
  83. if (!res || res.code !== 0)
  84. return Message.error(`获取历史对话消息失败!${res?.msg ?? ''}`)
  85. messages.value = res.data
  86. messages.value.push({
  87. type: 'system', time: new Date().getTime(), content: '你好!我是你的专属AI智能助手“小吉”,你可以问我任何问题哦~~\n试着问问:\n- 我拥有哪些Git仓库?\n- 仓库GitNexus最近一次提交的信息是什么?\n- 请对比electron仓库的最后两次提交。'
  88. })
  89. } catch (error) {
  90. Message.error(`获取历史对话消息失败!`)
  91. } finally {
  92. scrollToBottom()
  93. messagesLoading.value = false
  94. }
  95. }
  96. const getAIChatMessage = async () => {
  97. try {
  98. const res = await GetAIChatMessage({ id: msgid.value })
  99. if (!res || res.code !== 0) {
  100. send.value = false
  101. return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
  102. }
  103. if (!res.data)
  104. return
  105. stopPolling()
  106. messages.value[newIndex.value].content = ''
  107. for (let i = 0; i < res.data.length; i++) {
  108. await new Promise(resolve => setTimeout(resolve, 30))
  109. messages.value[newIndex.value].content += res.data[i]
  110. scrollToBottom()
  111. }
  112. send.value = false
  113. } catch (error) {
  114. Message.error(`获取对话消息失败!`)
  115. } finally {
  116. scrollToBottom()
  117. }
  118. }
  119. let timer = null
  120. // 轮询
  121. const startPolling = () => {
  122. if (!timer) {
  123. timer = setInterval(async () => {
  124. await getAIChatMessage()
  125. }, 1000)
  126. }
  127. }
  128. // 停止轮询
  129. const stopPolling = () => {
  130. if (timer) {
  131. clearInterval(timer)
  132. timer = null
  133. }
  134. send.value = false
  135. }
  136. const sendMessage = async () => {
  137. if (input.value === '') return
  138. send.value = true
  139. let content = input.value
  140. input.value = ''
  141. try {
  142. messages.value.push({ type: 'user', time: new Date().getTime(), content })
  143. newIndex.value = messages.value.push({ type: 'ai', time: new Date().getTime(), content: '小吉正在思考哦~请稍候...' }) - 1
  144. scrollToBottom()
  145. const res = await AIChat({ message: content })
  146. if (!res || res.code !== 0) {
  147. send.value = false
  148. return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
  149. }
  150. msgid.value = res.id
  151. startPolling()
  152. } catch (error) {
  153. console.log(error)
  154. Message.error('获取对话消息失败!请稍后再试')
  155. }
  156. }
  157. const closeAI = () => {
  158. eventBus.emit('closeAI')
  159. }
  160. const getuser = async () => {
  161. const userStore = useUserStore()
  162. let userInfo = await userStore.getInfo()
  163. user.value = userInfo
  164. }
  165. onMounted(async () => {
  166. getuser()
  167. await getAIChatMessages()
  168. })
  169. // 组件销毁时停止轮询
  170. onUnmounted(() => {
  171. stopPolling()
  172. })
  173. watch(
  174. () => props.visible,
  175. (val) => {
  176. if (val) {
  177. nextTick(() => {
  178. scrollToBottom()
  179. })
  180. }
  181. }
  182. )
  183. const stramptoTime = (time) => {
  184. return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  185. }
  186. const deleteMessages = () => {
  187. Modal.confirm({
  188. title: '清空消息记录',
  189. content: '您即将清空所有消息记录,是否继续?',
  190. onOk: async () => {
  191. const res = await DeleteAIChatMessages()
  192. if (!res || res.code !== 0)
  193. return Message.error('清空消息记录失败!')
  194. getAIChatMessages()
  195. Message.success('清空消息记录成功!')
  196. },
  197. onCancel: () => {
  198. }
  199. })
  200. }
  201. </script>
  202. <style lang="less" scoped>
  203. .container {
  204. display: flex;
  205. flex-direction: column;
  206. height: calc(100% + 20px);
  207. gap: 10px;
  208. margin-top: -20px;
  209. /* 对于 Webkit 浏览器(Chrome、Safari) */
  210. .messages::-webkit-scrollbar {
  211. width: 8px;
  212. }
  213. .messages::-webkit-scrollbar-track {
  214. background: transparent;
  215. }
  216. .messages::-webkit-scrollbar-thumb {
  217. background-color: rgba(0, 0, 0, 0.2);
  218. /* 滚动条滑块背景 */
  219. border-radius: 10px;
  220. /* 滚动条滑块圆角 */
  221. }
  222. .messages::-webkit-scrollbar-thumb:hover {
  223. background-color: rgba(0, 0, 0, 0.3);
  224. /* 滚动条滑块悬停时的背景 */
  225. }
  226. .messages {
  227. flex: 1;
  228. overflow-y: auto;
  229. padding: 10px;
  230. display: flex;
  231. flex-direction: column;
  232. .time {
  233. color: #888;
  234. font-size: 0.9em;
  235. text-align: center;
  236. margin: 10px 0 0;
  237. }
  238. .message {
  239. display: flex;
  240. margin-top: 10px;
  241. &.right {
  242. flex-direction: row-reverse;
  243. justify-content: end;
  244. }
  245. .avatar {
  246. user-select: none;
  247. width: 36px;
  248. height: 36px;
  249. background-color: #fff;
  250. }
  251. /* 对于 Webkit 浏览器(Chrome、Safari) */
  252. .content::-webkit-scrollbar {
  253. height: 6px;
  254. }
  255. .content::-webkit-scrollbar-track {
  256. background: transparent;
  257. }
  258. .content::-webkit-scrollbar-thumb {
  259. background-color: rgba(0, 0, 0, 0.2);
  260. /* 滚动条滑块背景 */
  261. border-radius: 10px;
  262. /* 滚动条滑块圆角 */
  263. }
  264. .content::-webkit-scrollbar-thumb:hover {
  265. background-color: rgba(0, 0, 0, 0.3);
  266. /* 滚动条滑块悬停时的背景 */
  267. }
  268. .content {
  269. max-width: 70%;
  270. padding: 0 14px;
  271. border-radius: 10px;
  272. margin: 0 10px;
  273. font-size: 14px;
  274. line-height: 1.5;
  275. word-break: break-word;
  276. overflow-x: auto;
  277. &.user {
  278. background-color: #4e88ff;
  279. color: white;
  280. }
  281. &.ai {
  282. background-color: #eee;
  283. color: #333;
  284. }
  285. }
  286. }
  287. }
  288. .inputBox {
  289. display: flex;
  290. gap: 10px;
  291. min-height: 15px;
  292. }
  293. }
  294. .aiTitle {
  295. display: flex;
  296. font-size: 1.1em;
  297. gap: 10px;
  298. width: 100%;
  299. .button {
  300. position: absolute;
  301. left: 75%;
  302. }
  303. }
  304. </style>