mirror of
https://gitee.com/ccnetcore/Yi
synced 2026-04-24 18:36:39 +08:00
756 lines
20 KiB
Vue
756 lines
20 KiB
Vue
<!-- 每个回话对应的聊天内容 -->
|
||
<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>
|