Files
AstrBot/dashboard/src/views/ChatPage.vue
T
IGCrystal 96b565e1e8 🎈 perf: comprehensive dashboard improvements
- Enhance i18n error handling and code quality - Fix SSE data processing in chat page - Improve responsive design for extension page - Add better debugging tools for development"
2025-06-16 21:05:20 +08:00

1600 lines
53 KiB
Vue

<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 }"
@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="box-shadow: 0 1px 2px rgba(0,0,0,0.1); 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-2"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" class="sidebar-panel" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<v-card class="conversation-list-card" v-if="conversations.length > 0" flat>
<v-list density="compact" nav class="conversation-list"
@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>
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-title-btn"
@click.stop="showEditTitleDialog(item.cid, item.title)" />
</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 v-if="!sidebarCollapsed">
<v-divider class="mx-2"></v-divider>
</div>
<div style="padding: 16px;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<div class="sidebar-section-title">
{{ tm('conversation.systemStatus') }}
</div>
<div class="status-chips">
<v-chip class="status-chip" :color="status?.llm_enabled ? 'primary' : 'grey-lighten-2'"
variant="outlined" size="small" rounded="sm">
<template v-slot:prepend>
<v-icon :icon="status?.llm_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
<span>{{ tm('conversation.llmService') }}</span>
</v-chip>
<v-chip class="status-chip" :color="status?.stt_enabled ? 'success' : 'grey-lighten-2'"
variant="outlined" size="small" rounded="sm">
<template v-slot:prepend>
<v-icon :icon="status?.stt_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
<span>{{ tm('conversation.speechToText') }}</span>
</v-chip>
</div>
<transition
name="expand"
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@before-leave="beforeLeave"
@leave="leave"
>
<div v-if="currCid" class="delete-btn-container">
<v-btn variant="outlined" rounded="sm" class="delete-chat-btn"
@click="deleteConversation(currCid)" color="error" density="comfortable" size="small">
<v-icon start size="small">mdi-delete</v-icon>
{{ tm('actions.deleteChat') }}
</v-btn>
</div>
</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="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" 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">
<span>{{ t('core.common.type') }}</span>
<code>help</code>
<span>{{ tm('shortcuts.help') }} 😊</span>
</div>
<div class="welcome-hint">
<span>{{ t('core.common.longPress') }}</span>
<code>Ctrl</code>
<span>{{ tm('shortcuts.voiceRecord') }} 🎤</span>
</div>
<div class="welcome-hint">
<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">
<span>{{ msg.message }}</span>
<!-- 图片附件 -->
<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" />
</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>
<v-avatar class="user-avatar" color="deep-purple-lighten-3" size="36">
<v-icon icon="mdi-account" />
</v-avatar>
</div>
<!-- 机器人消息 -->
<div v-else class="bot-message">
<v-avatar class="bot-avatar" color="deep-purple" size="36">
<span class="text-h6">✨</span>
</v-avatar>
<div class="message-bubble bot-bubble">
<div v-html="marked(msg.message)" class="markdown-content"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area fade-in">
<v-text-field autocomplete="off" id="input-field" variant="outlined" v-model="prompt"
:label="inputFieldLabel" :placeholder="tm('input.placeholder')" :loading="loadingChat"
clear-icon="mdi-close-circle" clearable @click:clear="clearMessage" class="message-input"
@keydown="handleInputKeyDown" hide-details>
<template v-slot:loader>
<v-progress-linear :active="loadingChat" height="3" color="deep-purple"
indeterminate></v-progress-linear>
</template>
<template v-slot:append>
<v-tooltip :text="tm('input.send')">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="sendMessage" class="send-btn" icon="mdi-send"
variant="text" color="deep-purple"
:disabled="!prompt && stagedImagesName.length === 0 && !stagedAudioUrl" />
</template>
</v-tooltip>
<v-tooltip :text="tm('input.voice')">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" @click="isRecording ? stopRecording() : startRecording()"
class="record-btn"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" />
</template>
</v-tooltip>
</template>
</v-text-field>
<!-- 附件预览区 -->
<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>
</template>
<script>
import { router } from '@/router';
import axios from 'axios';
import { marked } from 'marked';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
marked.setOptions({
breaks: true
});
export default {
name: 'ChatPage',
components: {
},
props: {
chatboxMode: {
type: Boolean,
default: false
}
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
return {
t,
tm,
router
};
},
data() {
return {
prompt: '',
messages: [],
conversations: [],
currCid: '',
stagedImagesName: [], // 用于存储图片**文件名**的数组
stagedImagesUrl: [], // 用于存储图片的blob URL数组
loadingChat: false,
inputFieldLabel: '',
isRecording: false,
audioChunks: [],
stagedAudioUrl: "",
mediaRecorder: null,
status: {},
statusText: '',
eventSource: 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
}
},
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) {
console.log('Route changed:', to.path);
// Check if the route matches /chat/<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('title');
this.startListeningEvent();
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();
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() {
if (this.eventSource) {
this.eventSource.cancel();
console.log('SSE连接已断开');
}
// 移除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 '';
}
},
async startListeningEvent() {
const response = await fetch('/api/chat/listen', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
if (!response.ok) {
console.error('SSE连接失败:', response.statusText);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
this.eventSource = reader
let in_streaming = false
let message_obj = null
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('SSE连接关闭');
break;
}
const chunk = decoder.decode(value, { stream: true });
// 可能有多行
let lines = chunk.split('\n\n');
console.log('SSE数据:', lines);
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) {
continue;
}
console.log(line)
// data: {"type": "plain", "data": "helloworld"}
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') {
console.warn('无效的数据对象:', chunk_json);
continue;
}
// 检查是否有type字段
if (!chunk_json.hasOwnProperty('type')) {
console.warn('数据缺少type字段:', chunk_json);
continue;
}
if (chunk_json.type === 'heartbeat') {
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: `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
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: `<audio controls class="audio-player">
<source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。
</audio>`
}
this.messages.push(bot_resp);
} else if (chunk_json.type === 'plain') {
if (!in_streaming) {
message_obj = {
type: 'bot',
message: 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 === 'end') {
in_streaming = false;
continue;
} 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;
}
} else {
console.warn('未知数据类型:', chunk_json.type);
}
this.scrollToBottom();
}
}
},
removeAudio() {
this.stagedAudioUrl = null;
},
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 = "录音中,请说话...";
},
async stopRecording() {
this.isRecording = false;
this.inputFieldLabel = "聊天吧!";
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 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();
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);
}
}
}
},
removeImage(index) {
this.stagedImagesName.splice(index, 1);
this.stagedImagesUrl.splice(index, 1);
},
clearMessage() {
this.prompt = '';
},
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);
message[i].message = `<img src="${imageUrl}" style="max-width: 80%; border-radius: 8px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);"/>`
}
if (message[i].message.startsWith('[RECORD]')) {
let audio = message[i].message.replace('[RECORD]', '');
const audioUrl = await this.getMediaFile(audio);
message[i].message = `<audio controls class="audio-player">
<source src="${audioUrl}" type="audio/wav">
您的浏览器不支持音频播放。
</audio>`
}
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;
}).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
};
return date.toLocaleString('zh-CN', 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);
});
},
async sendMessage() {
if (this.currCid == '') {
const cid = await this.newConversation();
// URL is already updated in newConversation method
}
// Create a message object with actual URLs for display
const userMessage = {
type: 'user',
message: this.prompt,
image_url: [],
audio_url: null
};
// Convert image filenames to blob URLs for display
if (this.stagedImagesName.length > 0) {
for (let i = 0; i < this.stagedImagesName.length; i++) {
// If it's just a filename, get the blob URL
if (!this.stagedImagesName[i].startsWith('blob:')) {
const imgUrl = await this.getMediaFile(this.stagedImagesName[i]);
userMessage.image_url.push(imgUrl);
} else {
userMessage.image_url.push(this.stagedImagesName[i]);
}
}
}
// Convert audio filename to blob URL for display
if (this.stagedAudioUrl) {
if (!this.stagedAudioUrl.startsWith('blob:')) {
userMessage.audio_url = await this.getMediaFile(this.stagedAudioUrl);
} else {
userMessage.audio_url = this.stagedAudioUrl;
}
}
this.messages.push(userMessage);
this.scrollToBottom();
this.loadingChat = true;
fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({
message: this.prompt,
conversation_id: this.currCid,
image_url: this.stagedImagesName, // Already contains just filenames
audio_url: this.stagedAudioUrl ? [this.stagedAudioUrl] : [] // Already contains just filename
})
})
.then(response => {
this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messageContainer;
container.scrollTop = container.scrollHeight;
});
},
handleInputKeyDown(e) {
if (e.keyCode === 17) { // Ctrl键
// 防止重复触发
if (this.ctrlKeyDown) return;
this.ctrlKeyDown = true;
// 设置定时器识别长按
this.ctrlKeyTimer = setTimeout(() => {
if (this.ctrlKeyDown && !this.isRecording) {
this.startRecording();
}
}, this.ctrlKeyLongPressThreshold);
}
},
handleInputKeyUp(e) {
if (e.keyCode === 17) { // Ctrl键
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 = {};
},
// For smooth height transition on delete button
beforeEnter(el) {
el.style.height = '0';
},
enter(el) {
el.style.height = el.scrollHeight + 'px';
},
afterEnter(el) {
el.style.height = 'auto';
},
beforeLeave(el) {
el.style.height = el.scrollHeight + 'px';
},
leave(el) {
el.style.height = '0';
},
},
}
</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 {
margin-bottom: 16px;
width: 100%;
height: 100%;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
}
.chat-page-container {
width: 100%;
height: 100%;
max-height: calc(100vh - 120px);
padding: 0;
}
.chat-layout {
height: 100%;
display: flex;
}
.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);
background-color: var(--v-theme-containerBg);
height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
/* 防止内容溢出 */
}
.sidebar-panel ::-webkit-scrollbar {
width: 6px;
}
.sidebar-panel ::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-panel ::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.sidebar-panel ::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
/* 侧边栏折叠状态 */
.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-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; /* Make border and text slightly transparent */
}
.status-chip {
font-size: 12px;
height: 24px !important;
}
.delete-chat-btn {
height: 32px !important;
width: 100%;
color: rgb(var(--v-theme-error)) !important;
font-weight: 500;
box-shadow: none !important;
margin-top: 8px;
text-transform: none;
letter-spacing: 0.25px;
font-size: 12px;
line-height: 1.2em;
transition: opacity 0.25s ease;
opacity: 0.7;
}
.delete-chat-btn:hover {
background-color: rgba(var(--v-theme-error-rgb), 0.1) !important;
}
.delete-btn-container {
/* margin-top: -8px; */ /* Removed for better layout practices */
}
.expand-enter-active,
.expand-leave-active {
transition: height 0.15s ease-in-out;
overflow: hidden;
}
.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%;
width: 100%;
display: flex;
flex-direction: column;
}
.messages-container {
height: calc(100% - 80px);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
/* 欢迎页样式 */
.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: var(--v-theme-codeBg);
padding: 2px 6px;
margin: 0 4px;
border-radius: 4px;
color: 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;
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
max-width: 80%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.user-bubble {
background-color: var(--v-theme-background);
color: var(--v-theme-primaryText);
border-top-right-radius: 4px;
}
.bot-bubble {
background-color: var(--v-theme-surface);
border: 1px solid var(--v-theme-border);
color: var(--v-theme-primaryText);
border-top-left-radius: 4px;
}
.user-avatar,
.bot-avatar {
align-self: flex-end;
}
/* 附件样式 */
.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);
}
.audio-attachment {
margin-top: 8px;
}
.audio-player {
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);
}
.message-input {
border-radius: 24px;
max-width: 900px;
margin: 0 auto;
}
.send-btn,
.record-btn {
margin-left: 4px;
}
/* 附件预览区 */
.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: 10px;
margin-bottom: 10px;
}
.markdown-content pre {
background-color: var(--v-theme-surface);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
}
.markdown-content code {
background-color: var(--v-theme-codeBg);
padding: 2px 4px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
color: var(--v-theme-code);
}
.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;
}
.conversation-header-content {
display: flex;
flex-direction: column;
}
.conversation-header-title {
font-size: 18px;
font-weight: 600;
margin: 0;
color: var(--v-theme-primaryText);
}
.conversation-header-time {
font-size: 12px;
color: var(--v-theme-secondaryText);
margin-top: 4px;
}
.conversation-header-actions {
display: flex;
align-items: center;
}
.fullscreen-icon {
opacity: 0.7;
transition: opacity 0.2s;
cursor: pointer;
}
.fullscreen-icon:hover {
opacity: 1;
}
</style>