425 lines
13 KiB
Vue
425 lines
13 KiB
Vue
<!-- 每个回话对应的聊天内容 -->
|
||
<script setup lang="ts">
|
||
import type { AnyObject } from 'typescript-api-pro';
|
||
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 { ArrowLeftBold, ArrowRightBold, Loading } from '@element-plus/icons-vue';
|
||
import { ElIcon, ElMessage } from 'element-plus';
|
||
import { useHookFetch } from 'hook-fetch/vue';
|
||
import { computed, nextTick, ref, watch } from 'vue';
|
||
import { Sender } from 'vue-element-plus-x';
|
||
import { useRoute } from 'vue-router';
|
||
import { send } from '@/api';
|
||
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';
|
||
import { systemProfilePicture, userProfilePicture } from '@/utils/user.ts';
|
||
import '@/styles/github-markdown.css';
|
||
import '@/styles/yixin-markdown.scss';
|
||
|
||
type MessageItem = BubbleProps & {
|
||
key: number;
|
||
role: 'ai' | 'user' | 'assistant';
|
||
avatar: string;
|
||
thinkingStatus?: ThinkingStatus;
|
||
thinlCollapse?: boolean;
|
||
reasoning_content?: string;
|
||
};
|
||
|
||
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> 结束,并处理标签中的内容为思考内容
|
||
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);
|
||
}
|
||
const isSending = ref(false);
|
||
|
||
async function startSSE(chatContent: string) {
|
||
if (isSending.value)
|
||
return;
|
||
|
||
try {
|
||
// 清空输入框
|
||
inputValue.value = '';
|
||
addMessage(chatContent, true);
|
||
addMessage('', false);
|
||
|
||
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
|
||
bubbleListRef.value?.scrollToBottom();
|
||
|
||
// 使用 for-await 处理流式响应
|
||
for await (const chunk of stream({
|
||
messages: bubbleItems.value.slice(0, -1).slice(-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) : 'not_login',
|
||
stream: true,
|
||
userId: userStore.userInfo?.userId,
|
||
model: modelStore.currentModelInfo.modelId ?? '',
|
||
})) {
|
||
handleDataChunk(chunk.result as AnyObject);
|
||
}
|
||
}
|
||
catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
console.log('用户中止请求'); // 正常中止,无需提示
|
||
}
|
||
else {
|
||
handleError(err); // 其他错误
|
||
}
|
||
}
|
||
finally {
|
||
isSending.value = false;
|
||
|
||
// 停止打字器状态
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 中断请求
|
||
async function cancelSSE() {
|
||
try {
|
||
cancel(); // 直接调用,无需参数
|
||
if (bubbleItems.value.length) {
|
||
bubbleItems.value[bubbleItems.value.length - 1].typing = false;
|
||
}
|
||
}
|
||
catch (err) {
|
||
handleError(err);
|
||
}
|
||
}
|
||
|
||
// 添加消息 - 维护聊天记录
|
||
function addMessage(message: string, isUser: boolean) {
|
||
const i = bubbleItems.value.length;
|
||
const obj: MessageItem = {
|
||
key: i,
|
||
avatar: isUser
|
||
? userProfilePicture
|
||
: systemProfilePicture,
|
||
avatarSize: '32px',
|
||
role: isUser ? 'user' : 'assistant',
|
||
placement: isUser ? 'end' : 'start',
|
||
isMarkdown: !isUser,
|
||
loading: !isUser,
|
||
content: message || '',
|
||
reasoning_content: '',
|
||
thinkingStatus: 'start',
|
||
thinlCollapse: false,
|
||
noStyle: !isUser,
|
||
};
|
||
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();
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
// 复制
|
||
function copy(item: any) {
|
||
console.log('复制', item);
|
||
navigator.clipboard.writeText(item.content || '')
|
||
.then(() => ElMessage.success('已复制到剪贴板'))
|
||
.catch(() => ElMessage.error('复制失败'));
|
||
}
|
||
</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>
|
||
<!-- 自定义气泡内容 -->
|
||
<template #content="{ item }">
|
||
<!-- chat 内容走 markdown -->
|
||
<XMarkdown v-if="item.content && (item.role === 'assistant' || item.role === 'system')" class="markdown-body" :markdown="item.content" :themes="{ light: 'github-light', dark: 'github-dark' }" default-theme-mode="dark" />
|
||
<!-- user 内容 纯文本 -->
|
||
<div v-if="item.content && item.role === 'user'" class="user-content">
|
||
{{ item.content }}
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 自定义底部 -->
|
||
<template #footer="{ item }">
|
||
<div class="footer-wrapper">
|
||
<div class="footer-container">
|
||
<div class="footer-time">
|
||
{{ item.creationTime }}
|
||
|
||
<el-button icon="DocumentCopy" size="small" circle @click="copy(item)" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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"
|
||
>
|
||
<ElIcon>
|
||
<ArrowLeftBold />
|
||
</ElIcon>
|
||
</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"
|
||
>
|
||
<ElIcon>
|
||
<ArrowRightBold />
|
||
</ElIcon>
|
||
</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>
|
||
<!-- 新增的 #suffix 模板 -->
|
||
<template #suffix>
|
||
<ElIcon v-if="isSending" class="is-loading">
|
||
<Loading />
|
||
</ElIcon>
|
||
</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;
|
||
}
|
||
.user-content {
|
||
// 换行
|
||
white-space: pre-wrap;
|
||
}
|
||
.markdown-body {
|
||
background-color: transparent;
|
||
}
|
||
.markdown-elxLanguage-header-div {
|
||
top: -25px !important;
|
||
}
|
||
|
||
// xmarkdown 样式
|
||
.elx-xmarkdown-container {
|
||
padding: 8px 4px;
|
||
}
|
||
}
|
||
.chat-defaul-sender {
|
||
width: 100%;
|
||
margin-bottom: 22px;
|
||
}
|
||
}
|
||
</style>
|