Files
AstrBot/dashboard/src/views/ChatPage.vue
RC-CHN b5d8173ee3 feat: add a file uplod button in WebChat page (#2136)
* feat:为webchat页面添加一个手动上传文件按钮(目前只处理图片)

* fix:上传后清空value,允许触发change事件以多次上传同一张图片

* perf:webchat页面消息发送后清空图片预览缩略图,维持与文本信息行为一致

* perf:将文件输入的值重置为空字符串以提升浏览器兼容性

* feat:webchat文件上传按钮支持多选文件上传

* fix:释放blob URL以防止内存泄漏

* perf:并行化sendMessage中的图片获取逻辑
2025-07-20 16:02:28 +08:00

2036 lines
69 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.

<template>
<v-card class="chat-page-card">
<v-card-text class="chat-page-container">
<div class="chat-layout">
<div class="sidebar-panel" :class="{ 'sidebar-collapsed': sidebarCollapsed }"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f5f5f5' }"
@mouseenter="handleSidebarMouseEnter" @mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/astrbot_logo_mini.webp" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed" style="font-weight: 1000; font-size: 26px; margin-left: 8px;"
class="text-secondary">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text"
color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="newC" :disabled="!currCid"
v-if="!sidebarCollapsed" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{
tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<v-card v-if="conversations.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" @update:selected="getConversationMessages">
<v-list-item v-for="(item, i) in conversations" :key="item.cid" :value="item.cid"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
|| tm('conversation.newConversation') }}</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
formatDate(item.updated_at)
}}</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="showEditTitleDialog(item.cid, item.title)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="deleteConversation(item.cid)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="conversations.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
{{ tm('conversation.noHistory') }}</div>
</div>
</v-fade-transition>
</div>
</div>
<!-- 右侧聊天内容区域 -->
<div class="chat-content-panel">
<div class="conversation-header fade-in">
<div class="conversation-header-content" v-if="currCid && getCurrentConversation">
<h2 class="conversation-header-title">{{ getCurrentConversation.title ||
tm('conversation.newConversation')
}}</h2>
<div class="conversation-header-time">{{ formatDate(getCurrentConversation.updated_at) }}
</div>
</div>
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props"
@click="router.push(currCid ? `/chatbox/${currCid}` : '/chatbox')"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</template>
</v-tooltip>
<!-- 语言切换按钮 -->
<v-tooltip :text="t('core.common.language')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<LanguageSwitcher variant="chatbox" />
</template>
</v-tooltip>
<!-- 主题切换按钮 -->
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- router 推送到 /chat -->
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="router.push(currCid ? `/chat/${currCid}` : '/chat')"
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
</template>
</v-tooltip>
</div>
</div>
<v-divider v-if="currCid && getCurrentConversation" class="conversation-divider"></v-divider>
<div class="messages-container" ref="messageContainer">
<!-- 空聊天欢迎页 -->
<div class="welcome-container fade-in" v-if="messages.length == 0">
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<div class="welcome-hint markdown-content">
<span>{{ t('core.common.type') }}</span>
<code>help</code>
<span>{{ tm('shortcuts.help') }} 😊</span>
</div>
<div class="welcome-hint markdown-content">
<span>{{ t('core.common.longPress') }}</span>
<code>Ctrl + B</code>
<span>{{ tm('shortcuts.voiceRecord') }} 🎤</span>
</div>
<div class="welcome-hint markdown-content">
<span>{{ t('core.common.press') }}</span>
<code>Ctrl + V</code>
<span>{{ tm('shortcuts.pasteImage') }} 🏞️</span>
</div>
</div>
<!-- 聊天消息列表 -->
<div v-else class="message-list">
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
<!-- 用户消息 -->
<div v-if="msg.type == 'user'" class="user-message">
<div class="message-bubble user-bubble"
:class="{ 'has-audio': msg.audio_url }"
:style="{ backgroundColor: isDark ? '#2d2e30' : '#e7ebf4' }">
<pre style="font-family: inherit; white-space: pre-wrap; word-wrap: break-word;">{{ msg.message }}</pre>
<!-- 图片附件 -->
<div class="image-attachments" v-if="msg.image_url && msg.image_url.length > 0">
<div v-for="(img, index) in msg.image_url" :key="index"
class="image-attachment">
<img :src="img" class="attached-image" @click="openImagePreview(img)" />
</div>
</div>
<!-- 音频附件 -->
<div class="audio-attachment" v-if="msg.audio_url && msg.audio_url.length > 0">
<audio controls class="audio-player">
<source :src="msg.audio_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
</div>
</div>
<!-- Bot Messages -->
<div v-else class="bot-message">
<v-avatar class="bot-avatar" size="36">
<span class="text-h2">✨</span>
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">
<!-- Text -->
<div v-if="msg.message && msg.message.trim()"
v-html="md.render(msg.message)"
class="markdown-content"></div>
<!-- Image -->
<div class="embedded-images" v-if="msg.embedded_images && msg.embedded_images.length > 0">
<div v-for="(img, imgIndex) in msg.embedded_images" :key="imgIndex"
class="embedded-image">
<img :src="img" class="bot-embedded-image" @click="openImagePreview(img)" />
</div>
</div>
<!-- Audio -->
<div class="embedded-audio" v-if="msg.embedded_audio">
<audio controls class="audio-player">
<source :src="msg.embedded_audio" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
</div>
<div class="message-actions">
<v-btn :icon="getCopyIcon(index)" size="small" variant="text"
class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.message, index)"
:title="t('core.common.copy')" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area fade-in">
<div
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px; padding: 4px;">
<textarea id="input-field" v-model="prompt" @keydown="handleInputKeyDown"
@click:clear="clearMessage" placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div
style="display: flex; justify-content: space-between; align-items: center; padding: 0px 8px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px;">
<!-- 选择提供商和模型 -->
<ProviderModelSelector ref="providerModelSelector" />
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px;">
<input type="file" ref="imageInput" @change="handleFileSelect" accept="image/*" style="display: none" multiple />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
class="add-btn" size="small" />
<v-btn @click="sendMessage" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl"
class="send-btn" size="small" />
<v-btn @click="isRecording ? stopRecording() : startRecording()"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn"
size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="removeImage(index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" />
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
<v-btn @click="removeAudio" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- 编辑对话标题对话框 -->
<v-dialog v-model="editTitleDialog" max-width="400">
<v-card>
<v-card-title class="dialog-title">{{ tm('actions.editTitle') }}</v-card-title>
<v-card-text>
<v-text-field v-model="editingTitle" :label="tm('conversation.newConversation')" variant="outlined"
hide-details class="mt-2" @keyup.enter="saveTitle" autofocus />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="editTitleDialog = false" color="grey-darken-1">{{ t('core.common.cancel') }}</v-btn>
<v-btn text @click="saveTitle" color="primary">{{ t('core.common.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script>
import { router } from '@/router';
import axios from 'axios';
import MarkdownIt from 'markdown-it';
import { ref } from 'vue';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import ProviderModelSelector from '@/components/chat/ProviderModelSelector.vue';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
// 配置markdown-it启用代码高亮
const md = new MarkdownIt({
html: false, // 禁用HTML标签防XSS
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.error('Highlight error:', err);
}
}
return hljs.highlightAuto(code).value;
}
});
export default {
name: 'ChatPage',
components: {
LanguageSwitcher,
ProviderModelSelector
},
props: {
chatboxMode: {
type: Boolean,
default: false
}
}, setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
return {
t,
tm,
router,
md,
ref
};
},
data() {
return {
prompt: '',
messages: [],
conversations: [],
currCid: '',
stagedImagesName: [], // 用于存储图片**文件名**的数组
stagedImagesUrl: [], // 用于存储图片的blob URL数组
loadingChat: false,
inputFieldLabel: '',
isRecording: false,
audioChunks: [],
stagedAudioUrl: "",
mediaRecorder: null,
status: {},
statusText: '',
eventSource: null,
eventSourceReader: null,
// // Ctrl键长按相关变量
ctrlKeyDown: false,
ctrlKeyTimer: null,
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
mediaCache: {}, // Add a cache to store media blobs
// 添加对话标题编辑相关变量
editTitleDialog: false,
editingTitle: '',
editingCid: '',
// 侧边栏折叠状态
sidebarCollapsed: false,
sidebarHovered: false,
sidebarHoverTimer: null,
sidebarHoverExpanded: false,
sidebarHoverDelay: 100, // 悬停延迟,单位毫秒
pendingCid: null, // Store pending conversation ID for route handling
// 复制成功提示
copySuccessMessage: null,
copySuccessTimeout: null,
copiedMessages: new Set(), // 存储已复制的消息索引
// 图片预览相关变量
imagePreviewDialog: false,
previewImageUrl: ''
}
},
computed: {
isDark() {
return useCustomizerStore().uiTheme === 'PurpleThemeDark';
},
// Get the current conversation from the conversations array
getCurrentConversation() {
if (!this.currCid) return null;
return this.conversations.find(c => c.cid === this.currCid);
}
},
watch: {
// Watch for route changes to handle direct navigation to /chat/<cid>
'$route': {
immediate: true,
handler(to, from) {
console.log('Route changed:', to.path, 'from:', from?.path); // 如果是从不同的路由模式切换chat <-> chatbox重新建立SSE连接
if (from &&
((from.path.startsWith('/chat') && to.path.startsWith('/chatbox')) ||
(from.path.startsWith('/chatbox') && to.path.startsWith('/chat')))) {
}
// Check if the route matches /chat/<cid> or /chatbox/<cid> pattern
if (to.path.startsWith('/chat/') || to.path.startsWith('/chatbox/')) {
const pathCid = to.path.split('/')[2];
console.log('Path CID:', pathCid);
if (pathCid && pathCid !== this.currCid) {
// If conversations are already loaded
if (this.conversations.length > 0) {
const conversation = this.conversations.find(c => c.cid === pathCid);
if (conversation) {
this.getConversationMessages([pathCid]);
}
} else {
// Store the cid to be used after conversations are loaded
this.pendingCid = pathCid;
}
}
}
}
},
// Watch for conversations loaded to handle pending cid
conversations: {
handler(newConversations) {
if (this.pendingCid && newConversations.length > 0) {
const conversation = newConversations.find(c => c.cid === this.pendingCid);
if (conversation) {
this.getConversationMessages([this.pendingCid]);
this.pendingCid = null;
}
}
}
}
},
mounted() {
// Theme is now handled globally by the customizer store.
// 设置输入框标签
this.inputFieldLabel = this.tm('input.chatPrompt');
this.checkStatus();
this.getConversations();
let inputField = document.getElementById('input-field');
inputField.addEventListener('paste', this.handlePaste);
inputField.addEventListener('keydown', function (e) {
if (e.keyCode == 13 && !e.shiftKey) {
e.preventDefault();
// 检查是否有内容可发送
if (this.canSendMessage()) {
this.sendMessage();
}
}
}.bind(this));
// 添加keyup事件监听
document.addEventListener('keyup', this.handleInputKeyUp);
// 从 localStorage 获取侧边栏折叠状态
const savedCollapseState = localStorage.getItem('sidebarCollapsed');
if (savedCollapseState !== null) {
this.sidebarCollapsed = JSON.parse(savedCollapseState);
}
},
beforeUnmount() {
// 移除keyup事件监听
document.removeEventListener('keyup', this.handleInputKeyUp);
// 清除悬停定时器
if (this.sidebarHoverTimer) {
clearTimeout(this.sidebarHoverTimer);
}
// Cleanup blob URLs
this.cleanupMediaCache();
},
methods: {
toggleTheme() {
const customizer = useCustomizerStore();
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
customizer.SET_UI_THEME(newTheme);
},
// 切换侧边栏折叠状态
toggleSidebar() {
if (this.sidebarHoverExpanded) {
this.sidebarHoverExpanded = false;
return
}
this.sidebarCollapsed = !this.sidebarCollapsed;
// 保存折叠状态到 localStorage
localStorage.setItem('sidebarCollapsed', JSON.stringify(this.sidebarCollapsed));
},
// 侧边栏鼠标悬停处理
handleSidebarMouseEnter() {
if (!this.sidebarCollapsed) return;
this.sidebarHovered = true;
// 设置延迟定时器
this.sidebarHoverTimer = setTimeout(() => {
if (this.sidebarHovered) {
this.sidebarHoverExpanded = true;
this.sidebarCollapsed = false;
}
}, this.sidebarHoverDelay);
},
handleSidebarMouseLeave() {
this.sidebarHovered = false;
// 清除定时器
if (this.sidebarHoverTimer) {
clearTimeout(this.sidebarHoverTimer);
this.sidebarHoverTimer = null;
}
if (this.sidebarHoverExpanded) {
this.sidebarCollapsed = true;
}
this.sidebarHoverExpanded = false;
},
// 显示编辑对话标题对话框
showEditTitleDialog(cid, title) {
this.editingCid = cid;
this.editingTitle = title || ''; // 如果标题为空,则设置为空字符串
this.editTitleDialog = true;
},
// 保存对话标题
saveTitle() {
if (!this.editingCid) return;
const trimmedTitle = this.editingTitle.trim();
axios.post('/api/chat/rename_conversation', {
conversation_id: this.editingCid,
title: trimmedTitle
})
.then(response => {
// 更新本地对话列表中的标题
const conversation = this.conversations.find(c => c.cid === this.editingCid);
if (conversation) {
conversation.title = trimmedTitle;
}
this.editTitleDialog = false;
})
.catch(err => {
console.error('重命名对话失败:', err);
});
},
async getMediaFile(filename) {
if (this.mediaCache[filename]) {
return this.mediaCache[filename];
}
try {
const response = await axios.get('/api/chat/get_file', {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
this.mediaCache[filename] = blobUrl;
return blobUrl;
} catch (error) {
console.error('Error fetching media file:', error);
return '';
}
},
showConnectionStatus(message, type) {
// You can implement a toast notification here or update UI status
console.log(`Connection status: ${message} (${type})`);
},
removeAudio() {
this.stagedAudioUrl = null;
},
openImagePreview(imageUrl) {
this.previewImageUrl = imageUrl;
this.imagePreviewDialog = true;
},
initImageClickEvents() {
this.$nextTick(() => {
// 查找所有动态生成的图片在markdown-content中
const images = document.querySelectorAll('.markdown-content img');
images.forEach((img) => {
if (!img.hasAttribute('data-click-enabled')) {
img.style.cursor = 'pointer';
img.setAttribute('data-click-enabled', 'true');
img.onclick = () => this.openImagePreview(img.src);
}
});
});
},
checkStatus() {
axios.get('/api/chat/status').then(response => {
console.log(response.data);
this.status = response.data.data;
}).catch(err => {
console.error(err);
});
},
async startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.start();
this.isRecording = true;
this.inputFieldLabel = this.tm('input.recordingPrompt');
},
async stopRecording() {
this.isRecording = false;
this.inputFieldLabel = this.tm('input.chatPrompt');
this.mediaRecorder.stop();
this.mediaRecorder.onstop = async () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
this.audioChunks = [];
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
const formData = new FormData();
formData.append('file', audioBlob);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const audio = response.data.data.filename;
console.log('Audio uploaded:', audio);
this.stagedAudioUrl = audio; // Store just the filename
} catch (err) {
console.error('Error uploading audio:', err);
}
};
},
async processAndUploadImage(file) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const img = response.data.data.filename;
this.stagedImagesName.push(img); // Store just the filename
this.stagedImagesUrl.push(URL.createObjectURL(file)); // Create a blob URL for immediate display
} catch (err) {
console.error('Error uploading image:', err);
}
},
async handlePaste(event) {
console.log('Pasting image...');
const items = event.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
this.processAndUploadImage(file);
}
}
},
removeImage(index) {
// Revoke the blob URL to prevent memory leaks
const urlToRevoke = this.stagedImagesUrl[index];
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
this.stagedImagesName.splice(index, 1);
this.stagedImagesUrl.splice(index, 1);
},
clearMessage() {
this.prompt = '';
},
triggerImageInput() {
this.$refs.imageInput.click();
},
handleFileSelect(event) {
const files = event.target.files;
if (files) {
for (const file of files) {
this.processAndUploadImage(file);
}
}
// Reset the input value to allow selecting the same file again
event.target.value = '';
},
getConversations() {
axios.get('/api/chat/conversations').then(response => {
this.conversations = response.data.data;
// If there's a pending conversation ID from the route
if (this.pendingCid) {
const conversation = this.conversations.find(c => c.cid === this.pendingCid);
if (conversation) {
this.getConversationMessages([this.pendingCid]);
this.pendingCid = null;
}
}
}).catch(err => {
if (err.response.status === 401) {
this.$router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
});
},
getConversationMessages(cid) {
if (!cid[0])
return;
// Update the URL to reflect the selected conversation
if (this.$route.path !== `/chat/${cid[0]}` && this.$route.path !== `/chatbox/${cid[0]}`) {
if (this.$route.path.startsWith('/chatbox')) {
this.$router.push(`/chatbox/${cid[0]}`);
} else {
this.$router.push(`/chat/${cid[0]}`);
}
}
axios.get('/api/chat/get_conversation?conversation_id=' + cid[0]).then(async response => {
this.currCid = cid[0];
let message = JSON.parse(response.data.data.history);
for (let i = 0; i < message.length; i++) {
if (message[i].message.startsWith('[IMAGE]')) {
let img = message[i].message.replace('[IMAGE]', '');
const imageUrl = await this.getMediaFile(img);
if (!message[i].embedded_images) {
message[i].embedded_images = [];
}
message[i].embedded_images.push(imageUrl);
message[i].message = ''; // 清空message避免显示标记文本
}
if (message[i].message.startsWith('[RECORD]')) {
let audio = message[i].message.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
message[i].embedded_audio = audioUrl;
message[i].message = ''; // 清空message避免显示标记文本
}
if (message[i].image_url && message[i].image_url.length > 0) {
for (let j = 0; j < message[i].image_url.length; j++) {
message[i].image_url[j] = await this.getMediaFile(message[i].image_url[j]);
}
}
if (message[i].audio_url) {
message[i].audio_url = await this.getMediaFile(message[i].audio_url);
}
}
this.messages = message;
this.initCodeCopyButtons();
this.initImageClickEvents();
}).catch(err => {
console.error(err);
});
},
async newConversation() {
return axios.get('/api/chat/new_conversation').then(response => {
const cid = response.data.data.conversation_id;
this.currCid = cid;
// Update the URL to reflect the new conversation
if (this.$route.path.startsWith('/chatbox')) {
this.$router.push(`/chatbox/${cid}`);
} else {
this.$router.push(`/chat/${cid}`);
}
this.getConversations();
return cid;
}).catch(err => {
console.error(err);
throw err;
});
},
newC() {
this.currCid = '';
this.messages = [];
if (this.$route.path.startsWith('/chatbox')) {
this.$router.push('/chatbox');
} else {
this.$router.push('/chat');
}
},
formatDate(timestamp) {
const date = new Date(timestamp * 1000); // 假设时间戳是以秒为单位
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// 使用当前语言环境的locale
const locale = this.t('core.common.locale') || 'zh-CN';
return date.toLocaleString(locale, options).replace(/\//g, '-').replace(/, /g, ' ');
},
deleteConversation(cid) {
axios.get('/api/chat/delete_conversation?conversation_id=' + cid).then(response => {
this.getConversations();
this.currCid = '';
this.messages = [];
}).catch(err => {
console.error(err);
});
},
// 检查是否可以发送消息
canSendMessage() {
return (this.prompt && this.prompt.trim()) ||
this.stagedImagesName.length > 0 ||
this.stagedAudioUrl;
},
async sendMessage() {
// 检查是否有内容可发送
if (!this.canSendMessage()) {
console.log('没有内容可发送');
return;
}
if (this.currCid == '') {
const cid = await this.newConversation();
// URL is already updated in newConversation method
}
// 保存当前要发送的数据到临时变量
const promptToSend = this.prompt.trim();
const imageNamesToSend = [...this.stagedImagesName];
const audioNameToSend = this.stagedAudioUrl;
// 立即清空输入和附件预览
this.prompt = '';
this.stagedImagesName = [];
this.stagedImagesUrl = [];
this.stagedAudioUrl = "";
// Create a message object with actual URLs for display
const userMessage = {
type: 'user',
message: promptToSend,
image_url: [],
audio_url: null
};
// Convert image filenames to blob URLs for display
if (imageNamesToSend.length > 0) {
const imagePromises = imageNamesToSend.map(name => {
if (!name.startsWith('blob:')) {
return this.getMediaFile(name);
}
return Promise.resolve(name);
});
userMessage.image_url = await Promise.all(imagePromises);
}
// Convert audio filename to blob URL for display
if (audioNameToSend) {
if (!audioNameToSend.startsWith('blob:')) {
userMessage.audio_url = await this.getMediaFile(audioNameToSend);
} else {
userMessage.audio_url = audioNameToSend;
}
}
this.messages.push(userMessage);
this.scrollToBottom();
this.loadingChat = true
// 从ProviderModelSelector组件获取当前选择
const selection = this.$refs.providerModelSelector?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
try {
const response = await fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({
message: promptToSend,
conversation_id: this.currCid,
image_url: imageNamesToSend,
audio_url: audioNameToSend ? [audioNameToSend] : [],
selected_provider: selectedProviderId,
selected_model: selectedModelName
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let in_streaming = false;
let message_obj = null;
while (true) {
try {
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) {
continue;
}
// Parse SSE data
let chunk_json;
try {
chunk_json = JSON.parse(line.replace('data: ', ''));
} catch (parseError) {
console.warn('JSON解析失败:', line, parseError);
continue;
}
// 检查解析后的数据是否有效
if (!chunk_json || typeof chunk_json !== 'object' || !chunk_json.hasOwnProperty('type')) {
console.warn('无效的数据对象:', chunk_json);
continue;
}
if (chunk_json.type === 'error') {
console.error('Error received:', chunk_json.data);
continue;
}
if (chunk_json.type === 'image') {
let img = chunk_json.data.replace('[IMAGE]', '');
const imageUrl = await this.getMediaFile(img);
let bot_resp = {
type: 'bot',
message: '',
embedded_images: [imageUrl]
}
this.messages.push(bot_resp);
} else if (chunk_json.type === 'record') {
let audio = chunk_json.data.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
let bot_resp = {
type: 'bot',
message: '',
embedded_audio: audioUrl
}
this.messages.push(bot_resp);
} else if (chunk_json.type === 'plain') {
if (!in_streaming) {
message_obj = {
type: 'bot',
message: this.ref(chunk_json.data),
}
this.messages.push(message_obj);
in_streaming = true;
} else {
message_obj.message.value += chunk_json.data;
}
} else if (chunk_json.type === 'update_title') {
// 更新对话标题
const conversation = this.conversations.find(c => c.cid === chunk_json.cid);
if (conversation) {
conversation.title = chunk_json.data;
}
}
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
// break means a segment end
in_streaming = false;
// 在消息流结束后初始化代码复制按钮和图片点击事件
this.initCodeCopyButtons();
this.initImageClickEvents();
}
this.scrollToBottom();
}
} catch (readError) {
console.error('SSE读取错误:', readError);
break;
}
}
// Input and attachments are already cleared
this.loadingChat = false;
// get the latest conversations
this.getConversations();
} catch (err) {
console.error('发送消息失败:', err);
this.loadingChat = false;
this.showConnectionStatus(this.tm('connection.status.failed'), 'error');
}
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
container.scrollTop = container.scrollHeight;
// 在滚动后初始化代码复制按钮和图片点击事件
this.initCodeCopyButtons();
this.initImageClickEvents();
});
},
handleInputKeyDown(e) {
if (e.ctrlKey && e.keyCode === 66) { // Ctrl+B组合键
e.preventDefault(); // 防止默认行为
// 防止重复触发
if (this.ctrlKeyDown) return;
this.ctrlKeyDown = true;
// 设置定时器识别长按
this.ctrlKeyTimer = setTimeout(() => {
if (this.ctrlKeyDown && !this.isRecording) {
this.startRecording();
}
}, this.ctrlKeyLongPressThreshold);
}
},
handleInputKeyUp(e) {
if (e.keyCode === 66) { // B键释放
this.ctrlKeyDown = false;
// 清除定时器
if (this.ctrlKeyTimer) {
clearTimeout(this.ctrlKeyTimer);
this.ctrlKeyTimer = null;
}
// 如果正在录音,停止录音
if (this.isRecording) {
this.stopRecording();
}
}
},
cleanupMediaCache() {
Object.values(this.mediaCache).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
this.mediaCache = {};
},
// 复制代码到剪贴板
copyCodeToClipboard(code) {
navigator.clipboard.writeText(code).then(() => {
console.log('代码已复制到剪贴板');
}).catch(err => {
console.error('复制失败:', err);
// 如果现代API失败使用传统方法
const textArea = document.createElement('textarea');
textArea.value = code;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('代码已复制到剪贴板 (fallback)');
} catch (fallbackErr) {
console.error('复制失败 (fallback):', fallbackErr);
}
document.body.removeChild(textArea);
});
},
// 复制bot消息到剪贴板
copyBotMessage(message, messageIndex) {
// 获取对应的消息对象
const msgObj = this.messages[messageIndex];
let textToCopy = '';
// 如果有文本消息,添加到复制内容中
if (message && message.trim()) {
// 移除HTML标签获取纯文本
const tempDiv = document.createElement('div');
tempDiv.innerHTML = message;
textToCopy = tempDiv.textContent || tempDiv.innerText || message;
}
// 如果有内嵌图片,添加说明
if (msgObj && msgObj.embedded_images && msgObj.embedded_images.length > 0) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += `[包含 ${msgObj.embedded_images.length} 张图片]`;
}
// 如果有内嵌音频,添加说明
if (msgObj && msgObj.embedded_audio) {
if (textToCopy) textToCopy += '\n\n';
textToCopy += '[包含音频内容]';
}
// 如果没有任何内容,使用默认文本
if (!textToCopy.trim()) {
textToCopy = '[媒体内容]';
}
navigator.clipboard.writeText(textToCopy).then(() => {
console.log('消息已复制到剪贴板');
this.showCopySuccess(messageIndex);
}).catch(err => {
console.error('复制失败:', err);
// 如果现代API失败使用传统方法
const textArea = document.createElement('textarea');
textArea.value = textToCopy;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
console.log('消息已复制到剪贴板 (fallback)');
this.showCopySuccess(messageIndex);
} catch (fallbackErr) {
console.error('复制失败 (fallback):', fallbackErr);
}
document.body.removeChild(textArea);
});
},
// 显示复制成功提示
showCopySuccess(messageIndex) {
this.copiedMessages.add(messageIndex);
// 2秒后移除成功状态
setTimeout(() => {
this.copiedMessages.delete(messageIndex);
}, 2000);
},
// 获取复制按钮图标
getCopyIcon(messageIndex) {
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy';
},
// 检查是否为复制成功状态
isCopySuccess(messageIndex) {
return this.copiedMessages.has(messageIndex);
},
// 获取复制图标SVG
getCopyIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
},
// 获取成功图标SVG
getSuccessIconSvg() {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
},
// 初始化代码块复制按钮
initCodeCopyButtons() {
this.$nextTick(() => {
const codeBlocks = this.$refs.messageContainer?.querySelectorAll('pre code') || [];
codeBlocks.forEach((codeBlock, index) => {
const pre = codeBlock.parentElement;
if (pre && !pre.querySelector('.copy-code-btn')) {
const button = document.createElement('button');
button.className = 'copy-code-btn';
button.innerHTML = this.getCopyIconSvg();
button.title = '复制代码';
button.addEventListener('click', () => {
this.copyCodeToClipboard(codeBlock.textContent);
// 显示复制成功提示
button.innerHTML = this.getSuccessIconSvg();
button.style.color = '#4caf50';
setTimeout(() => {
button.innerHTML = this.getCopyIconSvg();
button.style.color = '';
}, 2000);
});
pre.style.position = 'relative';
pre.appendChild(button);
}
});
});
},
},
}
</script>
<style>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
@keyframes slideIn {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 添加淡入动画 */
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.chat-page-card {
width: 100%;
height: calc(100vh - 84px);
max-height: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
overflow: hidden;
}
.chat-page-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.05);
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
/* 侧边栏折叠状态 */
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
/* 当悬停展开时 */
.sidebar-collapsed.sidebar-hovered {
max-width: 270px;
min-width: 240px;
transition: all 0.3s ease;
}
/* 侧边栏折叠按钮 */
.sidebar-collapse-btn-container {
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.conversation-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
height: auto !important;
min-height: 56px;
padding: 8px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.sidebar-section-title {
font-size: 12px;
font-weight: 500;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
padding-left: 4px;
transition: opacity 0.25s ease;
white-space: nowrap;
}
.status-chips {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 8px;
transition: opacity 0.25s ease;
}
.status-chips .v-chip {
flex: 1 1 0;
justify-content: center;
opacity: 0.7;
}
.status-chip {
font-size: 12px;
height: 24px !important;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages-container {
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* 欢迎页样式 */
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 16px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.welcome-hint {
margin-top: 8px;
color: var(--v-theme-secondaryText);
font-size: 14px;
}
.welcome-hint code {
background-color: rgb(var(--v-theme-codeBg));
padding: 2px 6px;
margin: 0 4px;
border-radius: 4px;
color: rgb(var(--v-theme-code));
font-family: 'Fira Code', monospace;
font-size: 13px;
}
/* 消息列表样式 */
.message-list {
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.message-item {
margin-bottom: 24px;
animation: fadeIn 0.3s ease-out;
}
.user-message {
display: flex;
justify-content: flex-end;
align-items: flex-start;
gap: 12px;
}
.bot-message {
display: flex;
justify-content: flex-start;
align-items: flex-start;
gap: 12px;
}
.bot-message-content {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
position: relative;
}
.message-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
margin-left: 8px;
}
.bot-message:hover .message-actions {
opacity: 1;
}
.copy-message-btn {
opacity: 0.6;
transition: all 0.2s ease;
color: var(--v-theme-secondary);
}
.copy-message-btn:hover {
opacity: 1;
background-color: rgba(103, 58, 183, 0.1);
}
.copy-message-btn.copy-success {
color: #4caf50;
opacity: 1;
}
.copy-message-btn.copy-success:hover {
color: #4caf50;
background-color: rgba(76, 175, 80, 0.1);
}
.message-bubble {
padding: 8px 16px;
border-radius: 12px;
}
.user-bubble {
color: var(--v-theme-primaryText);
padding: 18px 20px;
font-size: 16px;
max-width: 60%;
border-radius: 1.5rem;
}
.bot-bubble {
border: 1px solid var(--v-theme-border);
color: var(--v-theme-primaryText);
font-size: 16px;
max-width: 100%;
}
.user-avatar,
.bot-avatar {
align-self: flex-start;
margin-top: 12px;
}
/* 附件样式 */
.image-attachments {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.image-attachment {
position: relative;
display: inline-block;
}
.attached-image {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.attached-image:hover {
transform: scale(1.02);
cursor: pointer;
}
/* 图片预览对话框样式 */
.image-preview-card {
background-color: var(--v-theme-surface) !important;
border: 1px solid var(--v-theme-border);
}
/* 亮色主题下的图片预览对话框 */
.v-theme--light .image-preview-card,
.v-theme--PurpleTheme .image-preview-card {
background-color: #ffffff !important;
border-color: #e0e0e0 !important;
}
/* 暗色主题下的图片预览对话框 */
.v-theme--dark .image-preview-card,
.v-theme--PurpleThemeDark .image-preview-card {
background-color: #1e1e1e !important;
border-color: #333333 !important;
}
/* 确保对话框标题栏和内容区域的背景色 */
.image-preview-card .v-card-title {
background-color: inherit;
}
.image-preview-card .v-card-text {
background-color: inherit;
}
.preview-image-large {
max-width: 100%;
max-height: 75vh;
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.audio-attachment {
margin-top: 8px;
min-width: 250px;
}
/* 包含音频的消息气泡最小宽度 */
.message-bubble.has-audio {
min-width: 280px;
}
.audio-player {
width: 100%;
height: 36px;
border-radius: 18px;
}
/* 输入区域样式 */
.input-area {
padding: 16px;
background-color: var(--v-theme-surface);
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
/* 防止输入区域被压缩 */
}
/* 附件预览区 */
.attachments-preview {
display: flex;
gap: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview,
.audio-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip {
height: 36px;
border-radius: 18px;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
/* Markdown内容样式 */
.markdown-content {
font-family: inherit;
line-height: 1.6;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 16px;
margin-bottom: 10px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.markdown-content h1 {
font-size: 1.8em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 6px;
}
.markdown-content h2 {
font-size: 1.5em;
}
.markdown-content h3 {
font-size: 1.3em;
}
.markdown-content li {
margin-left: 16px;
margin-bottom: 4px;
}
.markdown-content p {
margin-top: .5rem;
margin-bottom: .5rem;
}
.markdown-content pre {
background-color: var(--v-theme-surface);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
position: relative;
}
.markdown-content code {
background-color: rgb(var(--v-theme-codeBg));
padding: 2px 4px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
color: var(--v-theme-code);
}
/* 代码块中的code标签样式 */
.markdown-content pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
color: inherit;
display: block;
overflow-x: auto;
line-height: 1.5;
}
/* 自定义代码高亮样式 */
.markdown-content pre {
border: 1px solid var(--v-theme-border);
background-color: rgb(var(--v-theme-preBg));
border-radius: 16px;
padding: 16px;
}
/* 确保highlight.js的样式正确应用 */
.markdown-content pre code.hljs {
background: transparent !important;
color: inherit;
}
/* 亮色主题下的代码高亮 */
.v-theme--light .markdown-content pre {
background-color: #f6f8fa;
}
/* 暗色主题下的代码块样式 */
.v-theme--dark .markdown-content pre {
background-color: #0d1117 !important;
border-color: rgba(255, 255, 255, 0.1);
}
.v-theme--dark .markdown-content pre code {
color: #e6edf3 !important;
}
/* 暗色主题下的highlight.js样式覆盖 */
.v-theme--dark .hljs {
background: #0d1117 !important;
color: #e6edf3 !important;
}
.v-theme--dark .hljs-keyword,
.v-theme--dark .hljs-selector-tag,
.v-theme--dark .hljs-built_in,
.v-theme--dark .hljs-name,
.v-theme--dark .hljs-tag {
color: #ff7b72 !important;
}
.v-theme--dark .hljs-string,
.v-theme--dark .hljs-title,
.v-theme--dark .hljs-section,
.v-theme--dark .hljs-attribute,
.v-theme--dark .hljs-literal,
.v-theme--dark .hljs-template-tag,
.v-theme--dark .hljs-template-variable,
.v-theme--dark .hljs-type,
.v-theme--dark .hljs-addition {
color: #a5d6ff !important;
}
.v-theme--dark .hljs-comment,
.v-theme--dark .hljs-quote,
.v-theme--dark .hljs-deletion,
.v-theme--dark .hljs-meta {
color: #8b949e !important;
}
.v-theme--dark .hljs-number,
.v-theme--dark .hljs-regexp,
.v-theme--dark .hljs-symbol,
.v-theme--dark .hljs-variable,
.v-theme--dark .hljs-template-variable,
.v-theme--dark .hljs-link,
.v-theme--dark .hljs-selector-attr,
.v-theme--dark .hljs-selector-pseudo {
color: #79c0ff !important;
}
.v-theme--dark .hljs-function,
.v-theme--dark .hljs-class,
.v-theme--dark .hljs-title.class_ {
color: #d2a8ff !important;
}
/* 复制按钮样式 */
.copy-code-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
padding: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 12px;
z-index: 10;
backdrop-filter: blur(4px);
}
.copy-code-btn:hover {
background: rgba(255, 255, 255, 1);
color: #333;
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.copy-code-btn:active {
transform: scale(0.95);
}
.markdown-content pre:hover .copy-code-btn {
opacity: 1;
}
.v-theme--dark .copy-code-btn {
background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.15);
color: #ccc;
}
.v-theme--dark .copy-code-btn:hover {
background: rgba(45, 45, 45, 1);
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.markdown-content img {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
.markdown-content blockquote {
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;
color: var(--v-theme-secondaryText);
margin: 16px 0;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--v-theme-background);
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background-color: var(--v-theme-containerBg);
}
/* 动画类 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* 对话框标题样式 */
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 8px;
}
/* 对话标题和时间样式 */
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 16px 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.embedded-images {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedded-image {
display: flex;
justify-content: flex-start;
}
.bot-embedded-image {
max-width: 80%;
width: auto;
height: auto;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease;
}
.bot-embedded-image:hover {
transform: scale(1.02);
}
.embedded-audio {
width: 300px;
margin-top: 8px;
}
.embedded-audio .audio-player {
width: 100%;
max-width: 300px;
}
</style>