Files
EasyFlow/easyflow-ui-admin/app/src/components/chat/chat.vue
陈子默 cac0fdf858 refactor: 调整聊天组件为按需引入
- 移除 bootstrap 中 vue-element-plus-x 全局组件注册

- 在聊天与会话页面改为局部引入对应组件
2026-03-11 22:06:14 +08:00

821 lines
23 KiB
Vue

<script setup lang="ts">
import type {
BubbleListInstance,
BubbleListProps,
} from 'vue-element-plus-x/types/BubbleList';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
import type { BotInfo, ChatMessage } from '@easyflow/types';
import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js';
import ElSender from 'vue-element-plus-x/es/Sender/index.js';
import ElThinking from 'vue-element-plus-x/es/Thinking/index.js';
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
import { IconifyIcon } from '@easyflow/icons';
import { $t } from '@easyflow/locales';
import { useBotStore } from '@easyflow/stores';
import { cloneDeep, cn, uuid } from '@easyflow/utils';
import {
ArrowDownBold,
CircleCheck,
CopyDocument,
Paperclip,
RefreshRight,
} from '@element-plus/icons-vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElIcon,
ElMessage,
ElSpace,
} from 'element-plus';
import { tryit } from 'radash';
import { getMessageList, getPerQuestions } from '#/api';
import { api, sseClient } from '#/api/request';
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
import SendIcon from '#/components/icons/SendIcon.vue';
import ShowJson from '#/components/json/ShowJson.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
import BotAvatar from '../botAvatar/botAvatar.vue';
import SendingIcon from '../icons/SendingIcon.vue';
type Think = {
reasoning_content?: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
};
type Tool = {
id: string;
name: string;
result: string;
status: 'TOOL_CALL' | 'TOOL_RESULT';
};
type MessageItem = ChatMessage & {
chains?: (Think | Tool)[];
};
const props = defineProps<{
bot?: BotInfo;
conversationId?: string;
// 是否显示对话列表
showChatConversations?: boolean;
}>();
const botStore = useBotStore();
interface historyMessageType {
role: string;
content: string;
}
interface presetQuestionsType {
key: string;
description: string;
}
const route = useRoute();
const botId = ref<string>((route.params.id as string) || '');
const router = useRouter();
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
const bubbleListRef = ref<BubbleListInstance>();
const messageContainerRef = ref<HTMLElement | null>(null);
const bubbleListScrollElement = ref<HTMLElement | null>(null);
const showBackToBottomButton = ref(false);
const senderRef = ref<InstanceType<typeof ElSender>>();
const senderValue = ref('');
const sending = ref(false);
const BACK_TO_BOTTOM_THRESHOLD = 160;
const getConversationId = async () => {
const res = await api.get('/api/v1/bot/generateConversationId');
return res.data;
};
const localeConversationId = ref<any>('');
const presetQuestions = ref<presetQuestionsType[]>([]);
defineExpose({
clear() {
bubbleItems.value = [];
messages.value = [];
},
});
const getPresetQuestions = () => {
api
.get('/api/v1/bot/detail', {
params: {
id: botId.value,
},
})
.then((res) => {
if (res.data.options?.presetQuestions) {
presetQuestions.value = res.data.options?.presetQuestions
.filter(
(item: presetQuestionsType) =>
item.description && item.description.trim() !== '',
)
.map((item: presetQuestionsType) => ({
key: item.key,
description: item.description,
}));
}
});
};
onMounted(async () => {
// 初始化 conversationId
localeConversationId.value =
props.conversationId && props.conversationId.length > 0
? props.conversationId
: await getConversationId();
getPresetQuestions();
nextTick(() => {
bindBubbleListScroll();
});
});
watchEffect(async () => {
if (props.bot && props.conversationId) {
const [, res] = await tryit(getMessageList)({
conversationId: props.conversationId,
botId: props.bot.id,
tempUserId: uuid() + props.bot.id,
});
if (res?.errorCode === 0) {
bubbleItems.value = res.data.map((item) => ({
...item,
content:
item.role === 'assistant'
? item.content.replace(/^Final Answer:\s*/i, '')
: item.content,
placement: item.role === 'assistant' ? 'start' : 'end',
}));
}
} else {
bubbleItems.value = [];
}
});
const lastUserMessage = ref('');
const messages = ref<historyMessageType[]>([]);
const updateBackToBottomButtonVisible = () => {
if (!bubbleListScrollElement.value) {
showBackToBottomButton.value = false;
return;
}
const { scrollTop, scrollHeight, clientHeight } = bubbleListScrollElement.value;
showBackToBottomButton.value =
scrollHeight - (scrollTop + clientHeight) > BACK_TO_BOTTOM_THRESHOLD;
};
const handleBubbleListScroll = () => {
updateBackToBottomButtonVisible();
};
const bindBubbleListScroll = () => {
const nextScrollElement = messageContainerRef.value?.querySelector(
'.el-bubble-list',
) as HTMLElement | null;
if (bubbleListScrollElement.value === nextScrollElement) {
updateBackToBottomButtonVisible();
return;
}
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.removeEventListener(
'scroll',
handleBubbleListScroll,
);
}
bubbleListScrollElement.value = nextScrollElement;
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.addEventListener(
'scroll',
handleBubbleListScroll,
{
passive: true,
},
);
}
updateBackToBottomButtonVisible();
};
const updateLastBubbleItem = (patch: Partial<MessageItem>) => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex < 0) {
return;
}
bubbleItems.value[lastIndex] = {
...bubbleItems.value[lastIndex]!,
...patch,
};
};
const finalizeLastBubbleItem = () => {
updateLastBubbleItem({
loading: false,
typing: false,
});
};
const stopSse = () => {
sseClient.abort();
sending.value = false;
finalizeLastBubbleItem();
};
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
const handleSubmit = async (refreshContent: string) => {
const attachments = attachmentsRef.value?.getFileList();
const currentPrompt = refreshContent || senderValue.value.trim();
if (!currentPrompt) {
return;
}
sending.value = true;
lastUserMessage.value = currentPrompt;
messages.value.push({
role: 'user',
content: currentPrompt,
});
const copyMessages = [...messages.value];
const data = {
botId: botId.value,
prompt: currentPrompt,
conversationId: localeConversationId.value,
messages: copyMessages,
attachments,
};
clearSenderFiles();
messages.value.pop();
const mockMessages = generateMockMessages(refreshContent);
bubbleItems.value.push(...mockMessages);
senderRef.value?.clear();
sseClient.post('/api/v1/bot/chat', data, {
onMessage(message) {
const event = message.event;
const lastIndex = bubbleItems.value.length - 1;
const lastBubbleItem = bubbleItems.value[lastIndex];
// finish
if (event === 'done') {
sending.value = false;
finalizeLastBubbleItem();
stopThinking();
return;
}
if (!message.data) {
return;
}
// 处理系统错误
const sseData = JSON.parse(message.data);
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
if (!lastBubbleItem) return;
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: errorMessage,
loading: false,
typing: false,
};
return;
}
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
const index = chains.findIndex(
(chain) =>
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
);
if (index === -1) {
chains.push({
id: sseData?.payload?.tool_call_id,
name: sseData?.payload?.name,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
});
} else {
chains[index] = {
...chains[index]!,
status: sseData?.type,
result:
sseData?.type === 'TOOL_CALL'
? sseData?.payload?.arguments
: sseData?.payload?.result,
};
}
bubbleItems.value[lastIndex]!.chains = chains;
stopThinking();
return;
}
// 处理流式消息
const delta = sseData.payload?.delta;
const role = sseData.payload?.role;
if (lastBubbleItem && delta) {
if (sseData.type === 'THINKING') {
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
const index = chains.findIndex(
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
);
if (index === -1) {
chains.push({
thinkingStatus: 'thinking',
thinlCollapse: true,
reasoning_content: delta,
});
} else {
const think = chains[index]! as Think;
chains[index] = {
...think,
reasoning_content: think.reasoning_content + delta,
};
}
bubbleItems.value[lastIndex]!.chains = chains;
} else if (sseData.type === 'MESSAGE') {
bubbleItems.value[lastIndex] = {
...lastBubbleItem,
content: (lastBubbleItem.content + delta).replaceAll(
'```echartsoption',
'```echarts\noption',
),
loading: false,
typing: true,
};
stopThinking();
}
}
// 是否需要保存聊天记录
if (event === 'needSaveMessage') {
messages.value.push({
role,
content: sseData.payload?.content,
});
}
},
onFinished() {
sending.value = false;
finalizeLastBubbleItem();
stopThinking();
},
onError(err) {
console.error(err);
sending.value = false;
finalizeLastBubbleItem();
},
});
};
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
const stopThinking = () => {
const lastIndex = bubbleItems.value.length - 1;
if (lastIndex >= 0 && bubbleItems.value[lastIndex]?.chains) {
const chains = cloneDeep(bubbleItems.value[lastIndex].chains);
for (const chain of chains) {
if (isThink(chain) && chain.thinkingStatus === 'thinking') {
chain.thinkingStatus = 'end';
}
}
bubbleItems.value[lastIndex].chains = chains;
}
};
const handleComplete = (_: TypewriterInstance, index: number) => {
if (
index === bubbleItems.value.length - 1 &&
props.conversationId &&
props.conversationId.length <= 0 &&
sending.value === false
) {
setTimeout(() => {
router.replace({
params: { conversationId: localeConversationId.value },
});
}, 100);
}
};
const generateMockMessages = (refreshContent: string) => {
const userMessage: MessageItem = {
role: 'user',
id: Date.now().toString(),
fileList: [],
content: refreshContent || senderValue.value,
created: Date.now(),
updateAt: Date.now(),
placement: 'end',
};
const assistantMessage: MessageItem = {
role: 'assistant',
id: Date.now().toString(),
content: '',
loading: true,
created: Date.now(),
updateAt: Date.now(),
placement: 'start',
};
return [userMessage, assistantMessage];
};
const handleCopy = (content: string) => {
navigator.clipboard
.writeText(content)
.then(() => ElMessage.success($t('message.copySuccess')))
.catch(() => ElMessage.error($t('message.copyFail')));
};
const handleRefresh = () => {
handleSubmit(lastUserMessage.value);
};
const scrollToBottom = () => {
bubbleListRef.value?.scrollToBottom();
if (!bubbleListRef.value && bubbleListScrollElement.value) {
bubbleListScrollElement.value.scrollTop =
bubbleListScrollElement.value.scrollHeight;
}
showBackToBottomButton.value = false;
};
const showHeaderFlog = ref(false);
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
const attachmentsRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
watch(
() => [localeConversationId.value, bubbleItems.value.length],
() => {
nextTick(() => {
bindBubbleListScroll();
});
},
{ flush: 'post' },
);
onBeforeUnmount(() => {
if (bubbleListScrollElement.value) {
bubbleListScrollElement.value.removeEventListener(
'scroll',
handleBubbleListScroll,
);
}
});
</script>
<template>
<div class="mx-auto h-full max-w-[780px]">
<div
:class="
cn(
'flex h-full w-full flex-col gap-3',
!localeConversationId && 'items-center justify-center gap-8',
)
"
>
<!-- 对话列表 -->
<div
v-if="localeConversationId || bubbleItems.length > 0"
ref="messageContainerRef"
class="message-container w-full flex-1 overflow-hidden"
>
<ElBubbleList
ref="bubbleListRef"
class="!h-full"
max-height="none"
:show-back-button="false"
:list="bubbleItems"
@complete="handleComplete"
>
<template #header="{ item }">
<div class="flex flex-col">
<span class="chat-bubble-item-time-style">
{{ new Date(item.created).toLocaleString() }}
</span>
<template v-if="item.chains">
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ElThinking
v-if="isThink(chain)"
v-model="chain.thinlCollapse"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
/>
<ElCollapse v-else class="mb-2">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ chain.name }}</span>
<template v-if="chain.status === 'TOOL_CALL'">
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16">
<IconifyIcon
icon="mdi:clock-time-five-outline"
/>
</ElIcon>
<span>{{ $t('bot.Running') }}...</span>
</div>
</template>
<template v-else>
<div
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
<span>{{ $t('bot.Completed') }}</span>
</div>
</template>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</template>
<!-- <ElThinking
v-if="item.reasoning_content"
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
class="mb-3"
/> -->
<!-- <ElCollapse v-if="item.tools" class="mb-2">
<ElCollapseItem
class="mb-2"
v-for="tool in item.tools"
:key="tool.id"
:title="tool.name"
:name="tool.id"
>
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ tool.name }}</span>
<template v-if="tool.status === 'TOOL_CALL'">
<ElIcon size="16">
<IconifyIcon icon="svg:spinner" />
</ElIcon>
</template>
<template v-else>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
</template>
</div>
</template>
<ShowJson :value="tool.result" />
</ElCollapseItem>
</ElCollapse> -->
</div>
</template>
<!-- 自定义头像 -->
<template #avatar="{ item }">
<BotAvatar
v-if="item.role === 'assistant'"
:src="bot?.icon"
:size="40"
/>
</template>
<template #content="{ item }">
<ElXMarkdown :markdown="item.content" />
</template>
<!-- 自定义底部 -->
<template #footer="{ item }">
<ElSpace :size="10">
<ElSpace>
<span @click="handleRefresh()" style="cursor: pointer">
<ElIcon>
<RefreshRight />
</ElIcon>
</span>
<span @click="handleCopy(item.content)" style="cursor: pointer">
<ElIcon>
<CopyDocument />
</ElIcon>
</span>
</ElSpace>
</ElSpace>
</template>
</ElBubbleList>
<button
v-if="showBackToBottomButton"
type="button"
class="chat-back-to-bottom-btn"
@click="scrollToBottom"
>
<ElIcon size="16">
<ArrowDownBold />
</ElIcon>
</button>
</div>
<!-- 新对话显示bot信息 -->
<div v-else class="flex flex-col items-center gap-3.5">
<BotAvatar :src="bot?.icon" :size="88" />
<h1 class="text-base font-medium text-black/85">
{{ bot?.title }}
</h1>
<span class="text-sm text-[#757575]">{{ bot?.description }}</span>
</div>
<!--问题预设-->
<div
class="questions-preset-container"
v-if="botStore.presetQuestions.length > 0"
>
<ElButton
v-for="item in getPerQuestions(botStore.presetQuestions)"
:key="item.key"
@click="handleSubmit(item.description)"
>
{{ item.description }}
</ElButton>
</div>
<!-- Sender -->
<ElSender
ref="senderRef"
class="w-full"
v-model="senderValue"
:placeholder="$t('message.pleaseInputContent')"
variant="updown"
:auto-size="{ minRows: 3, maxRows: 6 }"
allow-speech
@submit="handleSubmit"
@paste-file="handlePasteFile"
>
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
:max-size="10"
/>
</template>
<template #action-list>
<ElSpace>
<ElButton circle @click="openCloseHeader">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<ElButton circle>
<ElIcon><Microphone /></ElIcon>
&lt;!&ndash; <ElIcon color="#0066FF"><RecordingIcon /></ElIcon> &ndash;&gt;
</ElButton>-->
<ElButton v-if="sending" circle @click="stopSse">
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
</ElButton>
<template v-else>
<ElButton v-if="!senderValue" circle disabled>
<SendIcon />
</ElButton>
<ElButton v-else circle @click="handleSubmit('')">
<SendEnableIcon />
</ElButton>
</template>
</ElSpace>
</template>
</ElSender>
</div>
</div>
</template>
<style scoped>
.questions-preset-container {
display: flex;
flex-flow: row nowrap;
gap: 10px;
align-items: center;
justify-content: flex-start;
width: 100%;
overflow: auto;
}
.message-container {
position: relative;
padding: 8px;
background-color: var(--bot-chat-message-container);
border-radius: 8px;
}
.dark .message-container {
border: 1px solid hsl(var(--border));
}
.chat-back-to-bottom-btn {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: var(--el-text-color-regular);
cursor: pointer;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 999px;
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.chat-back-to-bottom-btn:hover {
color: var(--el-color-primary);
transform: translateY(-1px);
}
:deep(.el-bubble-content-wrapper .el-bubble-content-filled[data-v-a52d8fe0]) {
background-color: var(--bot-chat-message-item-back);
}
.chat-bubble-item-time-style {
font-size: 12px;
color: var(--common-font-placeholder-color);
}
.el-bubble-list :deep(.el-bubble.el-bubble-start) {
--bubble-content-max-width: calc(
100% - var(--el-bubble-avatar-placeholder-gap)
) !important;
}
.el-bubble-list :deep(.el-bubble.el-bubble-end) {
--bubble-content-max-width: calc(
100% -
calc(
var(--el-bubble-avatar-placeholder-gap) + var(--el-avatar-size, 40px)
)
) !important;
}
:deep(.el-bubble-header) {
width: 100%;
}
:deep(.el-bubble-end .el-bubble-header) {
width: fit-content;
}
:deep(.el-thinking) {
margin: 0;
}
:deep(.el-thinking .content-wrapper) {
--el-thinking-content-wrapper-width: var(--bubble-content-max-width);
margin-bottom: 8px;
}
:deep(.el-collapse-item) {
overflow: hidden;
border-radius: 8px;
}
:deep(.el-collapse-item__content) {
padding-bottom: 0;
}
</style>