| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- <template>
- <a-drawer :visible="props.visible" class="drawer" width="570px" @cancel="closeAI" placement="left" :footer="false">
- <template #title>
- <div class="aiTitle">
- <img alt="AI" src="@/assets/ai.svg" height="25">
- <span>AI助手</span>
- <a-button size="small" class="button" @click="deleteMessages">清空记录</a-button>
- </div>
- </template>
- <div class="container">
- <div class="messages" ref="messagesContainer">
- <a-spin :loading="messagesLoading">
- <div v-for="(item, index) in messages">
- <div class="time"
- v-if="!messages[index - 1] || (messages[index].time - messages[index - 1].time) > 300000">{{
- stramptoTime(item.time) }}</div>
- <div :class="['message', item.type === 'user' ? 'right' : 'left']">
- <a-avatar class="avatar">
- <img alt="avatar" src="@/assets/img/avatar/assistant.png" v-if="item.type !== 'user'" />
- <img alt="avatar" :src="user.avatar" v-else-if="item.type === 'user'" />
- </a-avatar>
- <a-dropdown trigger="contextMenu" alignPoint :style="{ display: 'block', zIndex: 99 }">
- <div :class="['content', item.type === 'user' ? 'user' : 'ai']"
- v-html="renderMarkdown(item.content)">
- </div>
- <template #content>
- <a-doption>
- <icon-play-arrow /> 打开仓库
- </a-doption>
- </template>
- </a-dropdown>
- </div>
- </div>
- </a-spin>
- </div>
- <div class="inputBox">
- <a-textarea placeholder="您的专属AI助手~" :max-length="300" :auto-size="{
- minRows: 1,
- maxRows: 6
- }" allow-clear v-model="input" />
- <a-button type="primary" :loading="send" @click="sendMessage">发送</a-button>
- </div>
- </div>
- </a-drawer>
- </template>
- <script setup>
- import { ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
- import { Message, Modal } from '@arco-design/web-vue'
- import { eventBus } from '@/utils/eventBus'
- import { AIChat, GetAIChatMessages, GetAIChatMessage, DeleteAIChatMessages } from '@/api/ai'
- import { useUserStore } from '@/store/modules/user'
- import MarkdownIt from 'markdown-it'
- const md = new MarkdownIt()
- const renderMarkdown = (text) => {
- return md.render(text || '')
- }
- const props = defineProps({
- visible: {
- type: Boolean,
- default: false
- }
- })
- const user = ref('')
- const input = ref('')
- const messagesLoading = ref(false)
- const send = ref(false)
- const messages = ref([])
- const msgid = ref('')
- const newIndex = ref()
- const messagesContainer = ref(null)
- const scrollToBottom = () => {
- if (messagesContainer.value) {
- // 等 DOM 渲染完成再滚动
- nextTick(() => {
- messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
- })
- }
- }
- const getAIChatMessages = async () => {
- try {
- messagesLoading.value = true
- const res = await GetAIChatMessages()
- if (!res || res.code !== 0)
- return Message.error(`获取历史对话消息失败!${res?.msg ?? ''}`)
- messages.value = res.data
- messages.value.push({
- type: 'system', time: new Date().getTime(), content: '你好!我是你的专属AI智能助手“小吉”,你可以问我任何问题哦~~\n试着问问:\n- 我拥有哪些Git仓库?\n- 仓库GitNexus最近一次提交的信息是什么?\n- 请对比electron仓库的最后两次提交。'
- })
- } catch (error) {
- Message.error(`获取历史对话消息失败!`)
- } finally {
- scrollToBottom()
- messagesLoading.value = false
- }
- }
- const getAIChatMessage = async () => {
- try {
- const res = await GetAIChatMessage({ id: msgid.value })
- if (!res || res.code !== 0) {
- send.value = false
- return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
- }
- if (!res.data)
- return
- stopPolling()
- messages.value[newIndex.value].content = ''
- for (let i = 0; i < res.data.length; i++) {
- await new Promise(resolve => setTimeout(resolve, 30))
- messages.value[newIndex.value].content += res.data[i]
- scrollToBottom()
- }
- send.value = false
- } catch (error) {
- Message.error(`获取对话消息失败!`)
- } finally {
- scrollToBottom()
- }
- }
- let timer = null
- // 轮询
- const startPolling = () => {
- if (!timer) {
- timer = setInterval(async () => {
- await getAIChatMessage()
- }, 1000)
- }
- }
- // 停止轮询
- const stopPolling = () => {
- if (timer) {
- clearInterval(timer)
- timer = null
- }
- send.value = false
- }
- const sendMessage = async () => {
- if (input.value === '') return
- send.value = true
- let content = input.value
- input.value = ''
- try {
- messages.value.push({ type: 'user', time: new Date().getTime(), content })
- newIndex.value = messages.value.push({ type: 'ai', time: new Date().getTime(), content: '小吉正在思考哦~请稍候...' }) - 1
- scrollToBottom()
- const res = await AIChat({ message: content })
- if (!res || res.code !== 0) {
- send.value = false
- return messages.value[newIndex.value].content = '获取对话消息失败,请稍后再试'
- }
- msgid.value = res.id
- startPolling()
- } catch (error) {
- console.log(error)
- Message.error('获取对话消息失败!请稍后再试')
- }
- }
- const closeAI = () => {
- eventBus.emit('closeAI')
- }
- const getuser = async () => {
- const userStore = useUserStore()
- let userInfo = await userStore.getInfo()
- user.value = userInfo
- }
- onMounted(async () => {
- getuser()
- await getAIChatMessages()
- })
- // 组件销毁时停止轮询
- onUnmounted(() => {
- stopPolling()
- })
- watch(
- () => props.visible,
- (val) => {
- if (val) {
- nextTick(() => {
- scrollToBottom()
- })
- }
- }
- )
- const stramptoTime = (time) => {
- return new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
- }
- const deleteMessages = () => {
- Modal.confirm({
- title: '清空消息记录',
- content: '您即将清空所有消息记录,是否继续?',
- onOk: async () => {
- const res = await DeleteAIChatMessages()
- if (!res || res.code !== 0)
- return Message.error('清空消息记录失败!')
- getAIChatMessages()
- Message.success('清空消息记录成功!')
- },
- onCancel: () => {
- }
- })
- }
- </script>
- <style lang="less" scoped>
- .container {
- display: flex;
- flex-direction: column;
- height: calc(100% + 20px);
- gap: 10px;
- margin-top: -20px;
- /* 对于 Webkit 浏览器(Chrome、Safari) */
- .messages::-webkit-scrollbar {
- width: 8px;
- }
- .messages::-webkit-scrollbar-track {
- background: transparent;
- }
- .messages::-webkit-scrollbar-thumb {
- background-color: rgba(0, 0, 0, 0.2);
- /* 滚动条滑块背景 */
- border-radius: 10px;
- /* 滚动条滑块圆角 */
- }
- .messages::-webkit-scrollbar-thumb:hover {
- background-color: rgba(0, 0, 0, 0.3);
- /* 滚动条滑块悬停时的背景 */
- }
- .messages {
- flex: 1;
- overflow-y: auto;
- padding: 10px;
- display: flex;
- flex-direction: column;
- .time {
- color: #888;
- font-size: 0.9em;
- text-align: center;
- margin: 10px 0 0;
- }
- .message {
- display: flex;
- margin-top: 10px;
- &.right {
- flex-direction: row-reverse;
- justify-content: end;
- }
- .avatar {
- user-select: none;
- width: 36px;
- height: 36px;
- background-color: #fff;
- }
- /* 对于 Webkit 浏览器(Chrome、Safari) */
- .content::-webkit-scrollbar {
- height: 6px;
- }
- .content::-webkit-scrollbar-track {
- background: transparent;
- }
- .content::-webkit-scrollbar-thumb {
- background-color: rgba(0, 0, 0, 0.2);
- /* 滚动条滑块背景 */
- border-radius: 10px;
- /* 滚动条滑块圆角 */
- }
- .content::-webkit-scrollbar-thumb:hover {
- background-color: rgba(0, 0, 0, 0.3);
- /* 滚动条滑块悬停时的背景 */
- }
- .content {
- max-width: 70%;
- padding: 0 14px;
- border-radius: 10px;
- margin: 0 10px;
- font-size: 14px;
- line-height: 1.5;
- word-break: break-word;
- overflow-x: auto;
- &.user {
- background-color: #4e88ff;
- color: white;
- }
- &.ai {
- background-color: #eee;
- color: #333;
- }
- }
- }
- }
- .inputBox {
- display: flex;
- gap: 10px;
- min-height: 15px;
- }
- }
- .aiTitle {
- display: flex;
- font-size: 1.1em;
- gap: 10px;
- width: 100%;
- .button {
- position: absolute;
- left: 75%;
- }
- }
- </style>
|