Files
Yi.Framework/Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
2025-08-03 21:56:51 +08:00

425 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 每个回话对应的聊天内容 -->
<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>