369 lines
12 KiB
Vue
369 lines
12 KiB
Vue
<!-- 每个回话对应的聊天内容 -->
|
||
<script setup lang="ts">
|
||
import type { AnyObject } from 'typescript-api-pro';
|
||
import type { Sender } from 'vue-element-plus-x';
|
||
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
||
import type { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
|
||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||
import { ElMessage } from 'element-plus';
|
||
import { useHookFetch } from 'hook-fetch/vue';
|
||
import { useRoute } from 'vue-router';
|
||
import { send } from '@/api';
|
||
import FilesSelect from '@/components/FilesSelect/index.vue';
|
||
import ModelSelect from '@/components/ModelSelect/index.vue';
|
||
import { useChatStore } from '@/stores/modules/chat';
|
||
import { useFilesStore } from '@/stores/modules/files';
|
||
import { useModelStore } from '@/stores/modules/model';
|
||
import { useUserStore } from '@/stores/modules/user';
|
||
|
||
type MessageItem = BubbleProps & {
|
||
key: number;
|
||
role: 'ai' | 'user' | 'assistant';
|
||
avatar: string;
|
||
thinkingStatus?: ThinkingStatus;
|
||
thinlCollapse?: boolean;
|
||
};
|
||
|
||
const route = useRoute();
|
||
const chatStore = useChatStore();
|
||
const modelStore = useModelStore();
|
||
const filesStore = useFilesStore();
|
||
const userStore = useUserStore();
|
||
|
||
// 用户头像
|
||
const avatar = computed(() => {
|
||
const userInfo = userStore.userInfo;
|
||
return userInfo?.avatar || 'https://avatars.githubusercontent.com/u/76239030?v=4';
|
||
});
|
||
|
||
const inputValue = ref('');
|
||
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
|
||
const bubbleItems = ref<MessageItem[]>([]);
|
||
const bubbleListRef = ref<BubbleListInstance | null>(null);
|
||
|
||
const { stream, loading: isLoading, cancel } = useHookFetch({
|
||
request: send,
|
||
onError: async (error) => {
|
||
if (error.status === 403) {
|
||
const data = await (error.response.json());
|
||
// 弹窗提示
|
||
ElMessage.error(data.error.message);
|
||
return Promise.reject(data);
|
||
}
|
||
if (error.status === 401) {
|
||
ElMessage.error('登录已过期,请重新登录!');
|
||
// 弹窗提示
|
||
userStore.logout();
|
||
userStore.openLoginDialog();
|
||
}
|
||
},
|
||
});
|
||
// 记录进入思考中
|
||
let isThinking = false;
|
||
|
||
watch(
|
||
() => route.params?.id,
|
||
async (_id_) => {
|
||
if (_id_) {
|
||
if (_id_ !== 'not_login') {
|
||
// 判断的当前会话id是否有聊天记录,有缓存则直接赋值展示
|
||
if (chatStore.chatMap[`${_id_}`] && chatStore.chatMap[`${_id_}`].length) {
|
||
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
|
||
// 滚动到底部
|
||
setTimeout(() => {
|
||
bubbleListRef.value!.scrollToBottom();
|
||
}, 350);
|
||
return;
|
||
}
|
||
|
||
// 无缓存则请求聊天记录
|
||
await chatStore.requestChatList(`${_id_}`);
|
||
// 请求聊天记录后,赋值回显,并滚动到底部
|
||
bubbleItems.value = chatStore.chatMap[`${_id_}`] as MessageItem[];
|
||
|
||
// 滚动到底部
|
||
setTimeout(() => {
|
||
bubbleListRef.value!.scrollToBottom();
|
||
}, 350);
|
||
}
|
||
|
||
// 如果本地有发送内容 ,则直接发送
|
||
const v = localStorage.getItem('chatContent');
|
||
if (v) {
|
||
// 发送消息
|
||
setTimeout(() => {
|
||
startSSE(v);
|
||
}, 350);
|
||
|
||
localStorage.removeItem('chatContent');
|
||
}
|
||
}
|
||
},
|
||
{ immediate: true, deep: true },
|
||
);
|
||
|
||
// 封装数据处理逻辑
|
||
function handleDataChunk(chunk: AnyObject) {
|
||
try {
|
||
const reasoningChunk = chunk.choices?.[0].delta.reasoning_content;
|
||
if (reasoningChunk) {
|
||
// 开始思考链状态
|
||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
|
||
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
|
||
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += reasoningChunk;
|
||
}
|
||
}
|
||
|
||
// 另一种思考中形式,content中有 <think></think> 的格式
|
||
// 一开始匹配到 <think> 开始,匹配到 </think> 结束,并处理标签中的内容为思考内容
|
||
console.log(chunk.choices, 'chunk.choices');
|
||
const parsedChunk = chunk.choices?.[0].delta.content;
|
||
if (parsedChunk) {
|
||
const thinkStart = parsedChunk.includes('<think>');
|
||
const thinkEnd = parsedChunk.includes('</think>');
|
||
if (thinkStart) {
|
||
isThinking = true;
|
||
}
|
||
if (thinkEnd) {
|
||
isThinking = false;
|
||
}
|
||
if (isThinking) {
|
||
// 开始思考链状态
|
||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'thinking';
|
||
bubbleItems.value[bubbleItems.value.length - 1].loading = true;
|
||
bubbleItems.value[bubbleItems.value.length - 1].thinlCollapse = true;
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].reasoning_content += parsedChunk
|
||
.replace('<think>', '')
|
||
.replace('</think>', '');
|
||
}
|
||
}
|
||
else {
|
||
// 结束 思考链状态
|
||
bubbleItems.value[bubbleItems.value.length - 1].thinkingStatus = 'end';
|
||
bubbleItems.value[bubbleItems.value.length - 1].loading = false;
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].content += parsedChunk;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (err) {
|
||
// 这里如果使用了中断,会有报错,可以忽略不管
|
||
console.error('解析数据时出错:', err);
|
||
}
|
||
}
|
||
|
||
// 封装错误处理逻辑
|
||
function handleError(err: any) {
|
||
console.error('Fetch error:', err);
|
||
}
|
||
|
||
async function startSSE(chatContent: string) {
|
||
try {
|
||
// 添加用户输入的消息
|
||
// console.log('chatContent', chatContent);
|
||
// 清空输入框
|
||
inputValue.value = '';
|
||
addMessage(chatContent, true);
|
||
addMessage('', false);
|
||
|
||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||
bubbleListRef.value?.scrollToBottom();
|
||
|
||
console.log('bubbleItems.value--', bubbleItems.value);
|
||
for await (const chunk of stream({
|
||
// messages: bubbleItems.value
|
||
// .filter((item: any) => item.role === 'user')
|
||
// .map((item: any) => ({
|
||
// role: item.role,
|
||
// content: item.content,
|
||
// })),
|
||
messages: bubbleItems.value
|
||
.slice(0, -1) // 去掉最后1条(即排除最新那条)
|
||
.slice(-6) // 然后取倒数第2到第7条(总共6条)
|
||
.map((item: MessageItem) => ({
|
||
role: item.role,
|
||
content: (item.role === 'ai' || item.role === 'assistant') && item.content.length > 2000
|
||
? `${item.content.substring(0, 2000)}...(内容过长,已省略)`
|
||
: item.content,
|
||
})),
|
||
sessionId: route.params?.id !== 'not_login' ? String(route.params?.id) : undefined,
|
||
userId: userStore.userInfo?.userId,
|
||
model: modelStore.currentModelInfo.modelId ?? '',
|
||
})) {
|
||
handleDataChunk(chunk.result as AnyObject);
|
||
}
|
||
}
|
||
catch (err) {
|
||
console.log('33---');
|
||
handleError(err);
|
||
}
|
||
finally {
|
||
console.log('数据接收完毕');
|
||
// 停止打字器状态
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 中断请求
|
||
async function cancelSSE() {
|
||
cancel();
|
||
// 结束最后一条消息打字状态
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||
}
|
||
}
|
||
|
||
// 添加消息 - 维护聊天记录
|
||
function addMessage(message: string, isUser: boolean) {
|
||
const i = bubbleItems.value.length;
|
||
const obj: MessageItem = {
|
||
key: i,
|
||
avatar: isUser
|
||
? avatar.value
|
||
: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
||
avatarSize: '32px',
|
||
role: isUser ? 'user' : 'assistant',
|
||
placement: isUser ? 'end' : 'start',
|
||
isMarkdown: !isUser,
|
||
loading: !isUser,
|
||
content: message || '',
|
||
reasoning_content: '',
|
||
thinkingStatus: 'start',
|
||
thinlCollapse: false,
|
||
};
|
||
bubbleItems.value.push(obj);
|
||
}
|
||
|
||
// 展开收起 事件展示
|
||
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
|
||
console.log('value', payload.value, 'status', payload.status);
|
||
}
|
||
|
||
function handleDeleteCard(_item: FilesCardProps, index: number) {
|
||
filesStore.deleteFileByIndex(index);
|
||
}
|
||
|
||
watch(
|
||
() => filesStore.filesList.length,
|
||
(val) => {
|
||
if (val > 0) {
|
||
nextTick(() => {
|
||
senderRef.value?.openHeader();
|
||
});
|
||
}
|
||
else {
|
||
nextTick(() => {
|
||
senderRef.value?.closeHeader();
|
||
});
|
||
}
|
||
},
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="chat-with-id-container">
|
||
<div class="chat-warp">
|
||
<BubbleList ref="bubbleListRef" :list="bubbleItems" max-height="calc(100vh - 240px)">
|
||
<template #header="{ item }">
|
||
<Thinking
|
||
v-if="item.reasoning_content" v-model="item.thinlCollapse" :content="item.reasoning_content"
|
||
:status="item.thinkingStatus" class="thinking-chain-warp" @change="handleChange"
|
||
/>
|
||
</template>
|
||
</BubbleList>
|
||
|
||
<Sender
|
||
ref="senderRef" v-model="inputValue" class="chat-defaul-sender" :auto-size="{
|
||
maxRows: 6,
|
||
minRows: 2,
|
||
}" variant="updown" clearable allow-speech :loading="isLoading" @submit="startSSE" @cancel="cancelSSE"
|
||
>
|
||
<template #header>
|
||
<div class="sender-header p-12px pt-6px pb-0px">
|
||
<Attachments :items="filesStore.filesList" :hide-upload="true" @delete-card="handleDeleteCard">
|
||
<template #prev-button="{ show, onScrollLeft }">
|
||
<div
|
||
v-if="show"
|
||
class="prev-next-btn left-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||
@click="onScrollLeft"
|
||
>
|
||
<el-icon>
|
||
<ArrowLeftBold />
|
||
</el-icon>
|
||
</div>
|
||
</template>
|
||
|
||
<template #next-button="{ show, onScrollRight }">
|
||
<div
|
||
v-if="show"
|
||
class="prev-next-btn right-8px flex-center w-22px h-22px rounded-8px border-1px border-solid border-[rgba(0,0,0,0.08)] c-[rgba(0,0,0,.4)] hover:bg-#f3f4f6 bg-#fff font-size-10px"
|
||
@click="onScrollRight"
|
||
>
|
||
<el-icon>
|
||
<ArrowRightBold />
|
||
</el-icon>
|
||
</div>
|
||
</template>
|
||
</Attachments>
|
||
</div>
|
||
</template>
|
||
<template #prefix>
|
||
<div class="flex-1 flex items-center gap-8px flex-none w-fit overflow-hidden">
|
||
<FilesSelect />
|
||
<ModelSelect />
|
||
</div>
|
||
</template>
|
||
</Sender>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
.chat-with-id-container {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: 100%;
|
||
max-width: 800px;
|
||
height: 100%;
|
||
.chat-warp {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
height: calc(100vh - 60px);
|
||
.thinking-chain-warp {
|
||
margin-bottom: 12px;
|
||
}
|
||
}
|
||
:deep() {
|
||
.el-bubble-list {
|
||
padding-top: 24px;
|
||
}
|
||
.el-bubble {
|
||
padding: 0 12px;
|
||
padding-bottom: 24px;
|
||
}
|
||
.el-typewriter {
|
||
overflow: hidden;
|
||
border-radius: 12px;
|
||
}
|
||
.markdown-body {
|
||
background-color: transparent;
|
||
}
|
||
}
|
||
.chat-defaul-sender {
|
||
width: 100%;
|
||
margin-bottom: 22px;
|
||
}
|
||
}
|
||
</style>
|