Files
EasyFlow/easyflow-ui-usercenter/app/src/components/chat/sender.vue
2026-02-22 18:56:10 +08:00

266 lines
6.8 KiB
Vue

<script setup lang="ts">
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { inject, ref } from 'vue';
import { cloneDeep, uuid } from '@easyflow/utils';
import { Paperclip, Promotion } from '@element-plus/icons-vue';
import { ElButton, ElIcon } from 'element-plus';
import { sseClient } from '#/api/request';
import SendingIcon from '#/components/icons/SendingIcon.vue';
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
// import PaperclipIcon from '#/components/icons/PaperclipIcon.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 = BubbleProps & {
chains?: (Think | Tool)[];
key: string;
role: 'assistant' | 'user';
};
interface Props {
conversationId: string | undefined;
bot: any;
addMessage: (message: MessageItem) => void;
updateLastMessage: (item: any) => void;
stopThinking: () => void;
}
const props = defineProps<Props>();
const senderValue = ref('');
const btnLoading = ref(false);
const getSessionList = inject<any>('getSessionList');
const clearSenderFiles = () => {
files.value = [];
attachmentsRef.value?.clearFiles();
openCloseHeader();
};
function sendMessage() {
if (getDisabled()) {
return;
}
const data = {
conversationId: props.conversationId,
prompt: senderValue.value,
botId: props.bot.id,
attachments: attachmentsRef.value?.getFileList(),
};
clearSenderFiles();
btnLoading.value = true;
props.addMessage({
key: uuid(),
role: 'user',
placement: 'end',
content: senderValue.value,
typing: true,
});
props.addMessage({
key: uuid(),
role: 'assistant',
placement: 'start',
content: '',
loading: true,
typing: true,
});
senderValue.value = '';
let content = '';
sseClient.post('/userCenter/bot/chat', data, {
onMessage(res) {
if (!res.data) {
return;
}
const sseData = JSON.parse(res.data);
const delta = sseData.payload?.delta;
if (res.event === 'done') {
btnLoading.value = false;
getSessionList();
}
// 处理系统错误
if (
sseData?.domain === 'SYSTEM' &&
sseData.payload?.code === 'SYSTEM_ERROR'
) {
const errorMessage = sseData.payload.message;
props.updateLastMessage({
content: errorMessage,
loading: false,
typing: false,
});
return;
}
if (sseData?.domain === 'TOOL') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.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,
};
}
return { chains };
});
props.stopThinking();
return;
}
if (sseData.type === 'THINKING') {
props.updateLastMessage((message: MessageItem) => {
const chains = cloneDeep(message.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,
};
}
return { chains };
});
} else if (sseData.type === 'MESSAGE') {
props.updateLastMessage({
thinkingStatus: 'end',
loading: false,
content: (content += delta),
});
props.stopThinking();
}
},
onError(err) {
console.error(err);
btnLoading.value = false;
},
onFinished() {
senderValue.value = '';
btnLoading.value = false;
props.updateLastMessage({ loading: false });
props.stopThinking();
},
});
}
const isTool = (item: Think | Tool) => {
return 'id' in item;
};
const isThink = (item: Think | Tool): item is Think => {
return !('id' in item);
};
function getDisabled() {
return !senderValue.value || !props.conversationId;
}
const stopSse = () => {
sseClient.abort();
btnLoading.value = false;
};
const showHeaderFlog = ref(false);
const attachmentsRef = ref();
const senderRef = ref();
const files = ref<any[]>([]);
function handlePasteFile(_: any, fileList: FileList) {
showHeaderFlog.value = true;
senderRef.value?.openHeader();
files.value = [...fileList];
}
function openCloseHeader() {
if (showHeaderFlog.value) {
senderRef.value?.closeHeader();
files.value = [];
} else {
senderRef.value?.openHeader();
}
showHeaderFlog.value = !showHeaderFlog.value;
}
</script>
<template>
<ElSender
ref="senderRef"
v-model="senderValue"
variant="updown"
:auto-size="{ minRows: 2, maxRows: 5 }"
clearable
allow-speech
placeholder="发送消息"
@keyup.enter="sendMessage"
@paste-file="handlePasteFile"
>
<!-- 自定义 prefix 前缀 -->
<!-- <template #prefix>
</template> -->
<!-- 自定义头部内容 -->
<template #header>
<ChatFileUploader
ref="attachmentsRef"
:external-files="files"
@delete-all="openCloseHeader"
:max-size="10"
/>
</template>
<template #action-list>
<div class="flex items-center gap-2">
<ElButton circle @click="openCloseHeader">
<ElIcon><Paperclip /></ElIcon>
</ElButton>
<!-- <ElButton :icon="PaperclipIcon" link /> -->
<ElButton v-if="btnLoading" circle @click="stopSse">
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
</ElButton>
<ElButton
v-else
type="primary"
:icon="Promotion"
:disabled="getDisabled()"
@click="sendMessage"
round
/>
</div>
</template>
</ElSender>
</template>