Files
Yi.Admin/Yi.Ai.Vue3/src/pages/chat/layouts/chatWithId/index.vue
2026-02-01 19:23:21 +08:00

756 lines
20 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 { BubbleListInstance } from 'vue-element-plus-x/types/BubbleList';
import { Loading } from '@element-plus/icons-vue';
import { ElIcon, ElMessage } from 'element-plus';
import { useHookFetch } from 'hook-fetch/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { Sender } from 'vue-element-plus-x';
import { useRoute } from 'vue-router';
import { deleteMessages, unifiedSend } from '@/api';
import { ChatHeader, DeleteModeToolbar, MessageItem } from '@/pages/chat/components';
import { useChatStore, useUserStore } from '@/stores';
import { useFilesStore } from '@/stores/modules/files';
import { useModelStore } from '@/stores/modules/model';
import { useSessionStore } from '@/stores/modules/session';
import { convertToApiFormat, parseStreamChunk, type UnifiedMessage } from '@/utils/apiFormatConverter';
import { useFilePaste, type MessageItem as MessageItemType } from '@/composables/chat';
import '@/styles/github-markdown.css';
import '@/styles/yixin-markdown.scss';
const route = useRoute();
const userStore = useUserStore();
const chatStore = useChatStore();
const modelStore = useModelStore();
const filesStore = useFilesStore();
const sessionStore = useSessionStore();
const currentSession = computed(() => sessionStore.currentSession);
const inputValue = ref('');
const senderRef = ref<InstanceType<typeof Sender> | null>(null);
const bubbleItems = ref<MessageItemType[]>([]);
const bubbleListRef = ref<BubbleListInstance | null>(null);
const isSending = ref(false);
// 删除模式相关状态
const isDeleteMode = ref(false);
const selectedMessageIds = ref<(number | string)[]>([]);
// 编辑模式相关状态
const editingMessageKey = ref<number | string | null>(null);
const editingContent = ref('');
// 临时ID计数器
let tempIdCounter = -1;
// 记录当前请求使用的 API 格式类型
const currentRequestApiType = ref('');
// 记录进入思考中
let isThinking = false;
// 文件处理相关常量
const MAX_FILE_SIZE = 3 * 1024 * 1024;
const MAX_TOTAL_CONTENT_LENGTH = 150000;
// 使用文件粘贴 composable
const { handlePaste } = useFilePaste({
maxFileSize: MAX_FILE_SIZE,
maxTotalContentLength: MAX_TOTAL_CONTENT_LENGTH,
getCurrentTotalLength: () => {
let total = 0;
filesStore.filesList.forEach((f) => {
if (f.fileType === 'text' && f.fileContent) {
total += f.fileContent.length;
}
if (f.fileType === 'image' && f.base64) {
total += Math.floor(f.base64.length * 0.5);
}
});
return total;
},
addFiles: (files) => filesStore.setFilesList([...filesStore.filesList, ...files]),
});
// 创建统一发送请求的包装函数
function unifiedSendWrapper(params: any) {
const { data, apiType, modelId, sessionId } = params;
return unifiedSend(data, apiType, modelId, sessionId);
}
const { stream, loading: isLoading, cancel } = useHookFetch({
request: unifiedSendWrapper,
onError: async (error) => {
isLoading.value = false;
if (error.status === 403) {
const data = await error.response.json();
ElMessage.error(data.error.message);
}
if (error.status === 401) {
ElMessage.error('登录已过期,请重新登录!');
userStore.logout();
userStore.openLoginDialog();
}
},
});
// 监听路由变化
watch(
() => route.params?.id,
async (id) => {
exitDeleteMode();
cancelEdit();
tempIdCounter = -1;
if (id && id !== 'not_login') {
// 有缓存则直接赋值展示
if (chatStore.chatMap[`${id}`]?.length) {
bubbleItems.value = chatStore.chatMap[`${id}`] as MessageItemType[];
scrollToBottom();
return;
}
// 无缓存则请求聊天记录
await chatStore.requestChatList(`${id}`);
bubbleItems.value = chatStore.chatMap[`${id}`] as MessageItemType[];
scrollToBottom();
}
// 本地有发送内容则直接发送
const v = localStorage.getItem('chatContent');
if (v) {
setTimeout(() => startSSE(v), 350);
localStorage.removeItem('chatContent');
}
},
{ immediate: true, deep: true },
);
function scrollToBottom() {
setTimeout(() => bubbleListRef.value?.scrollToBottom(), 350);
}
/**
* 处理流式响应的数据块
*/
function handleDataChunk(chunk: AnyObject) {
try {
const parsed = parseStreamChunk(chunk, currentRequestApiType.value || 'Completions');
// 处理消息ID和创建时间
// UserMessage 对应用户消息倒数第二条SystemMessage 对应AI消息最后一条
if (parsed.type === 'UserMessage' && parsed.messageId) {
const userMessage = bubbleItems.value[bubbleItems.value.length - 2];
if (userMessage) {
userMessage.id = parsed.messageId;
if (parsed.creationTime) {
userMessage.creationTime = parsed.creationTime;
}
}
}
else if (parsed.type === 'SystemMessage' && parsed.messageId) {
const aiMessage = bubbleItems.value[bubbleItems.value.length - 1];
if (aiMessage) {
aiMessage.id = parsed.messageId;
if (parsed.creationTime) {
aiMessage.creationTime = parsed.creationTime;
}
}
}
const latest = bubbleItems.value[bubbleItems.value.length - 1];
// 处理 token 使用情况
if (parsed.usage) {
latest.tokenUsage = {
prompt: parsed.usage.prompt_tokens || 0,
completion: parsed.usage.completion_tokens || 0,
total: parsed.usage.total_tokens || 0,
};
}
// 处理推理内容
if (parsed.reasoning_content) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsed.reasoning_content;
}
// 处理普通内容
if (parsed.content) {
const thinkStart = parsed.content.includes('<think>');
const thinkEnd = parsed.content.includes('</think>');
if (thinkStart) isThinking = true;
if (thinkEnd) isThinking = false;
if (isThinking) {
latest.thinkingStatus = 'thinking';
latest.loading = true;
latest.thinlCollapse = true;
latest.reasoning_content += parsed.content.replace('<think>', '').replace('</think>', '');
} else {
latest.thinkingStatus = 'end';
latest.loading = false;
latest.content += parsed.content;
}
}
} catch (err) {
console.error('解析数据时出错:', err);
}
}
/**
* 发送消息并处理流式响应
*/
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;
currentRequestApiType.value = modelStore.currentModelInfo.modelApiType || 'Completions';
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');
// 添加消息
addMessage(chatContent, true, imageFiles, textFiles);
addMessage('', false);
// 立即清空文件列表
filesStore.clearFilesList();
// 滚动到底部
bubbleListRef.value?.scrollToBottom();
// 组装消息内容
const messagesContent = buildMessagesContent(imageFiles, textFiles);
const apiType = modelStore.currentModelInfo.modelApiType || 'Completions';
const convertedRequest = convertToApiFormat(
messagesContent as UnifiedMessage[],
apiType,
modelStore.currentModelInfo.modelId ?? '',
true,
);
const sessionId = route.params?.id !== 'not_login' ? String(route.params?.id) : 'not_login';
for await (const chunk of stream({
data: convertedRequest,
apiType,
modelId: modelStore.currentModelInfo.modelId ?? '',
sessionId,
})) {
handleDataChunk(chunk.result as AnyObject);
}
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Fetch error:', err);
}
} finally {
finishSending();
}
}
/**
* 构建消息内容
*/
function buildMessagesContent(imageFiles: any[], textFiles: any[]) {
return bubbleItems.value.slice(0, -1).slice(-6).map((item: MessageItemType, index, arr) => {
const baseMessage: any = { role: item.role };
const isCurrentMessage = index === arr.length - 1;
if (item.role === 'user' && isCurrentMessage) {
const contentArray: any[] = [];
if (item.content) {
contentArray.push({ type: 'text', text: item.content });
}
// 添加文本文件内容
if (textFiles.length > 0) {
let fileContent = '\n\n';
textFiles.forEach((fileItem, idx) => {
fileContent += `<ATTACHMENT_FILE>\n`;
fileContent += `<FILE_INDEX>File ${idx + 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 (idx < textFiles.length - 1) fileContent += '\n';
});
contentArray.push({ type: 'text', text: fileContent });
}
// 添加图片
imageFiles.forEach((fileItem) => {
if (fileItem.base64) {
contentArray.push({
type: 'image_url',
image_url: { url: fileItem.base64, name: fileItem.name },
});
}
});
baseMessage.content = contentArray.length > 1 || imageFiles.length > 0 || textFiles.length > 0
? contentArray
: item.content;
} else {
baseMessage.content = (item.role === 'ai' || item.role === 'assistant') && item.content.length > 10000
? `${item.content.substring(0, 10000)}...(内容过长,已省略)`
: item.content;
}
return baseMessage;
});
}
/**
* 添加消息到聊天列表
*/
function addMessage(
message: string,
isUser: boolean,
imageFiles?: any[],
textFiles?: any[],
) {
const tempId = tempIdCounter--;
const obj: MessageItemType = {
key: tempId,
id: tempId,
role: isUser ? 'user' : 'assistant',
placement: isUser ? 'end' : 'start',
isMarkdown: !isUser,
loading: !isUser,
content: message || '',
reasoning_content: '',
thinkingStatus: 'start',
thinlCollapse: false,
noStyle: !isUser,
shape: isUser ? 'corner' : undefined,
images: imageFiles?.length ? imageFiles.map(f => ({ url: f.base64!, name: f.name })) : undefined,
files: textFiles?.length ? textFiles.map(f => ({ name: f.name!, size: f.fileSize! })) : undefined,
};
bubbleItems.value.push(obj);
}
/**
* 完成发送
*/
function finishSending() {
isSending.value = false;
const latest = bubbleItems.value[bubbleItems.value.length - 1];
if (latest) {
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;
}
}
/**
* 中断请求
*/
function cancelSSE() {
cancel();
finishSending();
}
// ==================== 删除模式 ====================
function enterDeleteMode(item?: MessageItemType) {
isDeleteMode.value = true;
selectedMessageIds.value = [];
if (item?.id) {
selectedMessageIds.value = [item.id];
}
}
function exitDeleteMode() {
isDeleteMode.value = false;
selectedMessageIds.value = [];
}
function toggleMessageSelection(item: MessageItemType) {
if (!item.id) return;
const index = selectedMessageIds.value.indexOf(item.id);
if (index > -1) {
selectedMessageIds.value.splice(index, 1);
} else {
selectedMessageIds.value.push(item.id);
}
}
async function confirmDelete() {
if (selectedMessageIds.value.length === 0) {
ElMessage.warning('请选择要删除的消息');
return;
}
const savedIds = selectedMessageIds.value.filter(id => typeof id === 'string' || (typeof id === 'number' && id > 0));
const tempIds = selectedMessageIds.value.filter(id => typeof id === 'number' && id < 0);
try {
if (savedIds.length > 0) {
await deleteMessages({ ids: savedIds, isDeleteSubsequent: false });
}
bubbleItems.value = bubbleItems.value.filter((item) => {
if (item.id === undefined) return true;
return !savedIds.includes(item.id) && !tempIds.includes(item.id as number);
});
// 更新缓存
if (route.params?.id && route.params.id !== 'not_login') {
chatStore.chatMap[`${route.params.id}`] = bubbleItems.value as any;
}
ElMessage.success('删除成功');
exitDeleteMode();
} catch (error) {
ElMessage.error('删除失败');
}
}
// ==================== 编辑模式 ====================
function startEditMessage(item: MessageItemType) {
editingMessageKey.value = item.key;
editingContent.value = item.content || '';
}
function cancelEdit() {
editingMessageKey.value = null;
editingContent.value = '';
}
async function submitEditMessage(item: MessageItemType) {
if (isSending.value || !editingContent.value.trim()) return;
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
if (itemIndex === -1) return;
const messageId = item.id;
const newContent = editingContent.value.trim();
cancelEdit();
try {
if (messageId !== undefined && (typeof messageId === 'string' || (typeof messageId === 'number' && messageId > 0))) {
await deleteMessages({ ids: [messageId], isDeleteSubsequent: true });
}
bubbleItems.value = bubbleItems.value.slice(0, itemIndex);
await startSSE(newContent);
} catch (error) {
ElMessage.error('编辑失败');
}
}
// ==================== 其他操作 ====================
function copy(item: MessageItemType) {
navigator.clipboard.writeText(item.content || '')
.then(() => ElMessage.success('已复制到剪贴板'))
.catch(() => ElMessage.error('复制失败'));
}
async function regenerateMessage(item: MessageItemType) {
if (isSending.value) return;
const itemIndex = bubbleItems.value.findIndex(msg => msg.key === item.key);
if (itemIndex === -1) return;
// 找到对应的用户消息
let targetUserMessageIndex = -1;
for (let i = itemIndex - 1; i >= 0; i--) {
if (bubbleItems.value[i]?.role === 'user') {
targetUserMessageIndex = i;
break;
}
}
if (targetUserMessageIndex === -1) {
ElMessage.error('未找到对应的用户消息');
return;
}
const targetUserMessage = bubbleItems.value[targetUserMessageIndex];
const userMessageId = targetUserMessage.id;
try {
if (userMessageId !== undefined && (typeof userMessageId === 'string' || (typeof userMessageId === 'number' && userMessageId > 0))) {
await deleteMessages({ ids: [userMessageId], isDeleteSubsequent: true });
}
bubbleItems.value = bubbleItems.value.slice(0, targetUserMessageIndex);
await startSSE(targetUserMessage.content || '');
} catch (error) {
ElMessage.error('重新生成失败');
}
}
function handleImagePreview(url: string) {
window.open(url, '_blank');
}
// 监听文件列表变化
watch(
() => filesStore.filesList.length,
(val) => {
nextTick(() => {
if (val > 0) {
senderRef.value?.openHeader();
} else {
senderRef.value?.closeHeader();
}
});
},
);
// 生命周期
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
});
</script>
<template>
<div class="chat-with-id">
<!-- 头部 -->
<ChatHeader show-title />
<!-- 聊天内容区域 -->
<div class="chat-with-id__content">
<!-- 删除模式工具栏 -->
<DeleteModeToolbar
v-if="isDeleteMode"
:selected-count="selectedMessageIds.length"
@confirm="confirmDelete"
@cancel="exitDeleteMode"
/>
<!-- 消息列表 -->
<BubbleList
ref="bubbleListRef"
:list="bubbleItems"
max-height="calc(100vh - 240px)"
:class="{ 'delete-mode': isDeleteMode }"
>
<template #content="{ item }">
<MessageItem
:item="item"
:is-delete-mode="isDeleteMode"
:is-editing="editingMessageKey === item.key"
:edit-content="editingContent"
:is-sending="isSending"
:is-selected="item.id ? selectedMessageIds.includes(item.id) : false"
@toggle-selection="toggleMessageSelection"
@edit="startEditMessage"
@cancel-edit="cancelEdit"
@submit-edit="submitEditMessage"
@update:edit-content="editingContent = $event"
@copy="copy"
@regenerate="regenerateMessage"
@delete="enterDeleteMode"
@image-preview="handleImagePreview"
/>
</template>
</BubbleList>
<!-- 发送器 -->
<Sender
ref="senderRef"
v-model="inputValue"
class="chat-with-id__sender"
:auto-size="{ maxRows: 6, minRows: 2 }"
variant="updown"
clearable
allow-speech
:loading="isLoading"
@submit="startSSE"
@cancel="cancelSSE"
>
<template #header>
<div class="chat-with-id__sender-header">
<Attachments
:items="filesStore.filesList"
:hide-upload="true"
@delete-card="(_, index) => filesStore.deleteFileByIndex(index)"
/>
</div>
</template>
<template #prefix>
<div class="chat-with-id__sender-prefix">
<FilesSelect />
<ModelSelect />
</div>
</template>
<template #suffix>
<ElIcon v-if="isSending" class="chat-with-id__loading">
<Loading />
</ElIcon>
</template>
</Sender>
</div>
</div>
</template>
<style scoped lang="scss">
.chat-with-id {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
height: 100%;
padding: 0 20px;
@media (max-width: 768px) {
padding: 0 12px;
}
@media (max-width: 480px) {
padding: 0 8px;
}
&__content {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
max-width: 1000px;
height: calc(100vh - 120px);
@media (max-width: 768px) {
height: calc(100vh - 110px);
}
@media (max-width: 480px) {
height: calc(100vh - 100px);
}
}
&__sender {
width: 100%;
margin-bottom: 22px;
@media (max-width: 768px) {
margin-bottom: 16px;
}
@media (max-width: 480px) {
margin-bottom: 12px;
}
}
&__sender-header {
padding: 12px;
padding-bottom: 0;
}
&__sender-prefix {
display: flex;
flex: 1;
align-items: center;
gap: 8px;
flex: none;
width: fit-content;
overflow: hidden;
@media (max-width: 768px) {
flex-wrap: wrap;
gap: 6px;
}
}
&__loading {
margin-left: 8px;
color: var(--el-color-primary);
animation: rotating 2s linear infinite;
}
}
// 气泡列表基础样式覆盖
:deep(.el-bubble-list) {
padding-top: 0;
}
:deep(.el-bubble) {
padding: 0;
width: 100% !important;
max-width: 100% !important;
&[class*="start"],
&[class*="end"] {
width: 100% !important;
max-width: 100% !important;
.el-bubble-content-wrapper {
width: 100% !important;
max-width: 100% !important;
flex: auto !important;
}
}
.el-avatar {
display: none !important;
}
.el-bubble-content {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
.el-bubble-content-wrapper {
width: 100% !important;
max-width: 100% !important;
}
}
// Typewriter 样式
:deep(.el-typewriter) {
overflow: hidden;
border-radius: 12px;
}
// Markdown 容器样式
:deep(.elx-xmarkdown-container) {
padding: 8px 4px;
}
// 代码块头部样式
:deep(.markdown-elxLanguage-header-div) {
top: -25px !important;
}
@keyframes rotating {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>