<script setup lang="ts">
|
import type { PropType } from 'vue';
|
|
import type { AiChatConversationApi } from '#/api/ai/chat/conversation';
|
import type { AiChatMessageApi } from '#/api/ai/chat/message';
|
|
import { computed, nextTick, onMounted, ref, toRefs } from 'vue';
|
|
import { IconifyIcon, SvgGptIcon } from '@vben/icons';
|
import { preferences } from '@vben/preferences';
|
import { useUserStore } from '@vben/stores';
|
import { formatDateTime } from '@vben/utils';
|
|
import { useClipboard } from '@vueuse/core';
|
import { Avatar, Button, message } from 'ant-design-vue';
|
|
import { deleteChatMessage } from '#/api/ai/chat/message';
|
import { MarkdownView } from '#/components/markdown-view';
|
|
import MessageFiles from './files.vue';
|
import MessageKnowledge from './knowledge.vue';
|
import MessageReasoning from './reasoning.vue';
|
import MessageWebSearch from './web-search.vue';
|
|
const props = defineProps({
|
conversation: {
|
type: Object as PropType<AiChatConversationApi.ChatConversation>,
|
required: true,
|
},
|
list: {
|
type: Array as PropType<AiChatMessageApi.ChatMessage[]>,
|
required: true,
|
},
|
});
|
|
const emits = defineEmits(['onDeleteSuccess', 'onRefresh', 'onEdit']);
|
const { copy } = useClipboard(); // 初始化 copy 到粘贴板
|
const userStore = useUserStore();
|
|
// 判断“消息列表”滚动的位置(用于判断是否需要滚动到消息最下方)
|
const messageContainer: any = ref(null);
|
const isScrolling = ref(false); // 用于判断用户是否在滚动
|
|
const userAvatar = computed(
|
() => userStore.userInfo?.avatar || preferences.app.defaultAvatar,
|
);
|
|
const { list } = toRefs(props); // 定义 emits
|
|
// ============ 处理对话滚动 ==============
|
|
/** 滚动到底部 */
|
async function scrollToBottom(isIgnore?: boolean) {
|
// 注意要使用 nextTick 以免获取不到 dom
|
await nextTick();
|
if (isIgnore || !isScrolling.value) {
|
messageContainer.value.scrollTop =
|
messageContainer.value.scrollHeight - messageContainer.value.offsetHeight;
|
}
|
}
|
|
function handleScroll() {
|
const scrollContainer = messageContainer.value;
|
const scrollTop = scrollContainer.scrollTop;
|
const scrollHeight = scrollContainer.scrollHeight;
|
const offsetHeight = scrollContainer.offsetHeight;
|
isScrolling.value = scrollTop + offsetHeight < scrollHeight - 100;
|
}
|
|
/** 回到底部 */
|
async function handleGoBottom() {
|
const scrollContainer = messageContainer.value;
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
}
|
|
/** 回到顶部 */
|
async function handlerGoTop() {
|
const scrollContainer = messageContainer.value;
|
scrollContainer.scrollTop = 0;
|
}
|
|
defineExpose({ scrollToBottom, handlerGoTop }); // 提供方法给 parent 调用
|
|
// ============ 处理消息操作 ==============
|
|
/** 复制 */
|
async function copyContent(content: string) {
|
await copy(content);
|
message.success('复制成功!');
|
}
|
|
/** 删除 */
|
async function handleDelete(id: number) {
|
// 删除 message
|
await deleteChatMessage(id);
|
message.success('删除成功!');
|
// 回调
|
emits('onDeleteSuccess');
|
}
|
|
/** 刷新 */
|
async function handleRefresh(message: AiChatMessageApi.ChatMessage) {
|
emits('onRefresh', message);
|
}
|
|
/** 编辑 */
|
async function handleEdit(message: AiChatMessageApi.ChatMessage) {
|
emits('onEdit', message);
|
}
|
|
/** 初始化 */
|
onMounted(async () => {
|
messageContainer.value.addEventListener('scroll', handleScroll);
|
});
|
</script>
|
<template>
|
<div ref="messageContainer" class="relative h-full overflow-y-auto">
|
<div
|
v-for="(item, index) in list"
|
:key="index"
|
class="mt-12 flex flex-col overflow-y-hidden px-5"
|
>
|
<!-- 左侧消息:system、assistant -->
|
<div v-if="item.type !== 'user'" class="flex flex-row">
|
<div class="avatar">
|
<Avatar
|
v-if="conversation.roleAvatar"
|
:src="conversation.roleAvatar"
|
:size="28"
|
/>
|
<SvgGptIcon v-else class="size-7" />
|
</div>
|
<div class="mx-4 flex flex-col text-left">
|
<div class="text-left leading-10">
|
{{ formatDateTime(item.createTime) }}
|
</div>
|
<div
|
class="relative flex flex-col break-words rounded-lg bg-gray-100 p-2.5 pb-1 pt-2.5 shadow-sm"
|
>
|
<MessageReasoning
|
:reasoning-content="item.reasoningContent || ''"
|
:content="item.content || ''"
|
/>
|
<MarkdownView
|
class="text-sm text-gray-600"
|
:content="item.content"
|
/>
|
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
<MessageKnowledge v-if="item.segments" :segments="item.segments" />
|
<MessageWebSearch
|
v-if="item.webSearchPages"
|
:web-search-pages="item.webSearchPages"
|
/>
|
</div>
|
<div class="mt-2 flex flex-row">
|
<Button
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="copyContent(item.content)"
|
>
|
<IconifyIcon icon="lucide:copy" />
|
</Button>
|
<Button
|
v-if="item.id > 0"
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="handleDelete(item.id)"
|
>
|
<IconifyIcon icon="lucide:trash" />
|
</Button>
|
</div>
|
</div>
|
</div>
|
|
<!-- 右侧消息:user -->
|
<div v-else class="flex flex-row-reverse justify-start">
|
<div class="avatar">
|
<Avatar :src="userAvatar" :size="28" />
|
</div>
|
<div class="mx-4 flex flex-col text-left">
|
<div class="text-left leading-8">
|
{{ formatDateTime(item.createTime) }}
|
</div>
|
<div
|
v-if="item.attachmentUrls && item.attachmentUrls.length > 0"
|
class="mb-2 flex flex-row-reverse"
|
>
|
<MessageFiles :attachment-urls="item.attachmentUrls" />
|
</div>
|
<div class="flex flex-row-reverse">
|
<div
|
v-if="item.content && item.content.trim()"
|
class="inline w-auto whitespace-pre-wrap break-words rounded-lg bg-blue-500 p-2.5 text-sm text-white shadow-sm"
|
>
|
{{ item.content }}
|
</div>
|
</div>
|
<div class="mt-2 flex flex-row-reverse">
|
<Button
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="copyContent(item.content)"
|
>
|
<IconifyIcon icon="lucide:copy" />
|
</Button>
|
<Button
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="handleDelete(item.id)"
|
>
|
<IconifyIcon icon="lucide:trash" />
|
</Button>
|
<Button
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="handleRefresh(item)"
|
>
|
<IconifyIcon icon="lucide:refresh-cw" />
|
</Button>
|
<Button
|
class="flex items-center bg-transparent px-1.5 hover:bg-gray-100"
|
type="text"
|
@click="handleEdit(item)"
|
>
|
<IconifyIcon icon="lucide:edit" />
|
</Button>
|
</div>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<!-- 回到底部按钮 -->
|
<div
|
v-if="isScrolling"
|
class="absolute bottom-0 right-1/2 z-1000"
|
@click="handleGoBottom"
|
>
|
<Button shape="circle">
|
<IconifyIcon icon="lucide:chevron-down" />
|
</Button>
|
</div>
|
</template>
|