Files
Yi.Framework/Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
2025-12-12 19:38:27 +08:00

484 lines
14 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 { useGuideTour } from '@/hooks/useGuideTour';
import { useGuideTourStore } from '@/stores';
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 { getUserProfilePicture, systemProfilePicture } from '@/utils/user.ts';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue';
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 guideTourStore = useGuideTourStore();
// 用户头像
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 isSending = ref(false);
const { stream, loading: isLoading, cancel } = useHookFetch({
request: send,
onError: async (error) => {
isLoading.value = false;
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 {
// 安全获取 delta 和 content
const delta = chunk.choices?.[0]?.delta;
const reasoningChunk = delta?.reasoning_content;
const parsedChunk = delta?.content;
// usage 处理(可以移动到 startSSE 里也可以写这里)
if (chunk.usage) {
const { prompt_tokens, completion_tokens, total_tokens } = chunk.usage;
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.tokenUsage = {
prompt: prompt_tokens,
completion: completion_tokens,
total: total_tokens,
};
}
if (reasoningChunk) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += reasoningChunk;
}
if (parsedChunk) {
const thinkStart = parsedChunk.includes('<think>');
const thinkEnd = parsedChunk.includes('</think>');
if (thinkStart)
isThinking = true;
if (thinkEnd)
isThinking = false;
const latest = bubbleItems.value[bubbleItems.value.length - 1];
if (isThinking) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsedChunk.replace('<think>', '').replace('</think>', '');
}
else {
latest.thinkingStatus = 'end';
latest.loading = false;
latest.content += parsedChunk;
}
}
}
catch (err) {
console.error('解析数据时出错:', err);
}
}
// 封装错误处理逻辑
function handleError(err: any) {
console.error('Fetch error:', err);
}
async function startSSE(chatContent: string) {
if (isSending.value)
return;
isSending.value = true;
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 > 10000
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
: 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); // 其他错误
// ElMessage.error('消息发送失败,请重试');
}
}
finally {
isSending.value = false;
// 停止打字器状态和加载状态
if (bubbleItems.value.length) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.typing = false;
latest.loading = false;
if (latest.thinkingStatus === 'thinking') {
latest.thinkingStatus = 'end';
}
}
}
}
// 中断请求
async function cancelSSE() {
try {
cancel(); // 直接调用,无需参数
isSending.value = false;
if (bubbleItems.value.length) {
const latest = bubbleItems.value[bubbleItems.value.length - 1];
latest.typing = false;
latest.loading = false;
if (latest.thinkingStatus === 'thinking') {
latest.thinkingStatus = 'end';
}
}
}
catch (err) {
handleError(err);
}
}
// 添加消息 - 维护聊天记录
function addMessage(message: string, isUser: boolean) {
const i = bubbleItems.value.length;
const obj: MessageItem = {
key: i,
avatar: isUser
? getUserProfilePicture()
: 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 }) {
}
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) {
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 -->
<YMarkdown 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">
<span v-if="item.creationTime "> {{ item.creationTime }}</span>
<span v-if="((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) " style="margin-left: 10px;" class="footer-token">
{{ ((item.role === 'ai' || item.role === 'assistant') && item?.tokenUsage?.total) ? `token:${item?.tokenUsage?.total}` : '' }}</span>
<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" data-tour="chat-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;
}
}
.footer-wrapper {
display: flex;
align-items: center;
gap: 10px;
.footer-time {
font-size: 12px;
margin-top: 3px;
.footer-token {
background: rgba(1, 183, 86, 0.53);
padding: 0 4px;
margin: 0 2px;
border-radius: 4px;
color: #ffffff;
}
}
}
.footer-container {
:deep(.el-button + .el-button) {
margin-left: 8px;
}
}
.loading-container {
font-size: 14px;
color: #333;
padding: 12px;
background: linear-gradient(to right, #fdfcfb 0%, #ffd1ab 100%);
border-radius: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.loading-container span {
display: inline-block;
margin-left: 8px;
}
</style>