Files
Yi.Framework/Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
2026-01-02 22:47:09 +08:00

750 lines
22 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, Document, 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 Collapse from '@/layouts/components0/Header/components/Collapse.vue';
import CreateChat from '@/layouts/components0/Header/components/CreateChat.vue';
import TitleEditing from '@/layouts/components0/Header/components/TitleEditing.vue';
import { useDesignStore, useGuideTourStore } from '@/stores';
import { useChatStore } from '@/stores/modules/chat';
import { useFilesStore } from '@/stores/modules/files';
import { useModelStore } from '@/stores/modules/model';
import { useSessionStore } from '@/stores/modules/session';
import { useUserStore } from '@/stores/modules/user';
import { getUserProfilePicture, systemProfilePicture } from '@/utils/user.ts';
import YMarkdown from '@/vue-element-plus-y/components/XMarkdown/index.vue';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
// 新增的导入
const designStore = useDesignStore();
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
type MessageItem = BubbleProps & {
key: number;
role: 'ai' | 'user' | 'assistant';
avatar: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
reasoning_content?: string;
images?: Array<{ url: string; name?: string }>; // 用户消息中的图片列表
files?: Array<{ name: string; size: number }>; // 用户消息中的文件列表
};
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 },
);
/**
* 处理流式响应的数据块
* 解析 AI 返回的数据,更新消息内容和思考状态
* @param {AnyObject} chunk - 流式响应的数据块
*/
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);
}
}
/**
* 处理错误信息
* @param {any} err - 错误对象
*/
function handleError(err: any) {
console.error('Fetch error:', err);
}
/**
* 发送消息并处理流式响应
* 支持发送文本、图片和文件
* @param {string} chatContent - 用户输入的文本内容
*/
async function startSSE(chatContent: string) {
if (isSending.value)
return;
// 检查是否有未上传完成的文件
const hasUnuploadedFiles = filesStore.filesList.some(f => !f.isUploaded);
if (hasUnuploadedFiles) {
ElMessage.warning('文件正在上传中,请稍候...');
return;
}
isSending.value = true;
try {
// 清空输入框
inputValue.value = '';
// 获取当前上传的图片和文件(在清空之前保存)
const imageFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'image');
const textFiles = filesStore.filesList.filter(f => f.isUploaded && f.fileType === 'text');
const images = imageFiles.map(f => ({
url: f.base64!, // 使用base64作为URL
name: f.name,
}));
const files = textFiles.map(f => ({
name: f.name!,
size: f.fileSize!,
}));
addMessage(chatContent, true, images, files);
addMessage('', false);
// 立即清空文件列表(不要等到响应完成)
filesStore.clearFilesList();
// 这里有必要调用一下 BubbleList 组件的滚动到底部 手动触发 自动滚动
bubbleListRef.value?.scrollToBottom();
// 组装消息内容,支持图片和文件
const messagesContent = bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItem) => {
const baseMessage: any = {
role: item.role,
};
// 如果是用户消息且有附件(图片或文件),组装成数组格式
if (item.role === 'user' && item.key === bubbleItems.value.length - 2) {
// 当前发送的消息
const contentArray: any[] = [];
// 添加文本内容
if (item.content) {
contentArray.push({
type: 'text',
text: item.content,
});
}
// 添加文本文件内容使用XML格式
if (textFiles.length > 0) {
let fileContent = '\n\n';
textFiles.forEach((fileItem, index) => {
fileContent += `<ATTACHMENT_FILE>\n`;
fileContent += `<FILE_INDEX>File ${index + 1}</FILE_INDEX>\n`;
fileContent += `<FILE_NAME>${fileItem.name}</FILE_NAME>\n`;
fileContent += `<FILE_CONTENT>\n${fileItem.fileContent}\n</FILE_CONTENT>\n`;
fileContent += `</ATTACHMENT_FILE>\n`;
if (index < textFiles.length - 1) {
fileContent += '\n';
}
});
contentArray.push({
type: 'text',
text: fileContent,
});
}
// 添加图片内容(使用之前保存的 imageFiles
imageFiles.forEach((fileItem) => {
if (fileItem.base64) {
contentArray.push({
type: 'image_url',
image_url: {
url: fileItem.base64, // 使用base64
name: fileItem.name, // 保存图片名称
},
});
}
});
// 如果有图片或文件,使用数组格式
if (contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0) {
baseMessage.content = contentArray;
}
else {
baseMessage.content = item.content;
}
}
else {
// 其他消息保持原样
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
: item.content;
}
return baseMessage;
});
// 使用 for-await 处理流式响应
for await (const chunk of stream({
messages: messagesContent,
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';
}
}
// 保存聊天记录到 chatMap本地缓存刷新后可恢复
if (route.params?.id && route.params.id !== 'not_login') {
chatStore.chatMap[`${route.params.id}`] = bubbleItems.value as any;
}
}
}
/**
* 中断正在进行的请求
* 停止流式响应并重置状态
*/
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);
}
}
/**
* 添加消息到聊天列表
* @param {string} message - 消息内容
* @param {boolean} isUser - 是否为用户消息
* @param {Array<{url: string, name?: string}>} images - 图片列表(可选)
* @param {Array<{name: string, size: number}>} files - 文件列表(可选)
*/
function addMessage(message: string, isUser: boolean, images?: Array<{ url: string; name?: string }>, files?: Array<{ name: string; size: number }>) {
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,
images: images && images.length > 0 ? images : undefined,
files: files && files.length > 0 ? files : undefined,
};
bubbleItems.value.push(obj);
}
/**
* 处理思考链展开/收起状态变化
* @param {object} payload - 状态变化的载荷
* @param {boolean} payload.value - 展开/收起状态
* @param {ThinkingStatus} payload.status - 思考状态
*/
function handleChange(payload: { value: boolean; status: ThinkingStatus }) {
}
/**
* 删除文件卡片
* @param {FilesCardProps} _item - 文件卡片项(未使用)
* @param {number} index - 要删除的文件索引
*/
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();
});
}
},
);
/**
* 复制消息内容到剪贴板
* @param {any} item - 消息项
*/
function copy(item: any) {
navigator.clipboard.writeText(item.content || '')
.then(() => ElMessage.success('已复制到剪贴板'))
.catch(() => ElMessage.error('复制失败'));
}
/**
* 图片预览
* 在新窗口中打开图片
* @param {string} url - 图片 URL
*/
function handleImagePreview(url: string) {
window.open(url, '_blank');
}
</script>
<template>
<div class="chat-with-id-container">
<!-- 头部导航栏 -->
<div class="chat-header">
<div
class="overflow-hidden flex h-full items-center flex-row flex-1 w-fit flex-shrink-0 min-w-0"
>
<div class="w-full flex items-center flex-row">
<!-- 左边 -->
<div
class="left-box flex h-full items-center pl-20px gap-12px flex-shrink-0 flex-row"
>
<Collapse />
<CreateChat />
<div v-if="currentSession" class="w-0.5px h-30px bg-[rgba(217,217,217)]" />
</div>
<!-- 中间 -->
<div class="middle-box flex-1 min-w-0 ml-12px">
<TitleEditing />
</div>
</div>
</div>
</div>
<!-- 聊天内容区域 -->
<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.role === 'user'" class="user-content-wrapper">
<!-- 图片列表 -->
<div v-if="item.images && item.images.length > 0" class="user-images">
<img
v-for="(image, index) in item.images"
:key="index"
:src="image.url"
:alt="image.name || '图片'"
class="user-image"
@click="() => handleImagePreview(image.url)"
>
</div>
<!-- 文件列表 -->
<div v-if="item.files && item.files.length > 0" class="user-files">
<div
v-for="(file, index) in item.files"
:key="index"
class="user-file-item"
>
<ElIcon class="file-icon">
<Document />
</ElIcon>
<span class="file-name">{{ file.name }}</span>
</div>
</div>
<!-- 文本内容 -->
<div v-if="item.content" class="user-content">
{{ item.content }}
</div>
</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 />
<!-- < ToolList/> -->
<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 {
padding: 0 20px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
.chat-header {
width: 100%;
//max-width: 1000px;
height: 60px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.chat-warp {
max-width: 1000px;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: calc(100vh - 120px); // 减去头部高度
.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-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.user-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
.user-files {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 4px;
}
.user-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 6px;
font-size: 13px;
.file-icon {
font-size: 16px;
color: #409eff;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #909399;
font-size: 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>