750 lines
22 KiB
Vue
750 lines
22 KiB
Vue
<!-- 每个回话对应的聊天内容 -->
|
||
<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>
|