feat: chatbox page

This commit is contained in:
Soulter
2025-06-10 15:02:18 +08:00
parent 581f9b7bd3
commit 60651736a5
4 changed files with 309 additions and 99 deletions
+14
View File
@@ -0,0 +1,14 @@
const ChatBoxRoutes = {
path: '/chatbox',
component: () => import('@/layouts/blank/BlankLayout.vue'),
children: [
{
name: 'ChatBox',
path: '/chatbox',
component: () => import('@/views/ChatBoxPage.vue')
}
]
};
export default ChatBoxRoutes;
+3 -1
View File
@@ -1,13 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router';
import MainRoutes from './MainRoutes';
import AuthRoutes from './AuthRoutes';
import ChatBoxRoutes from './ChatBoxRoutes';
import { useAuthStore } from '@/stores/auth';
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
MainRoutes,
AuthRoutes
AuthRoutes,
ChatBoxRoutes
]
});
+36
View File
@@ -0,0 +1,36 @@
<script setup>
import ChatPage from './ChatPage.vue';
</script>
<template>
<div style="height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<div id="container">
<ChatPage chatbox-mode="true"></ChatPage>
</div>
</div>
</template>
<style scoped>
#container {
width: 100%;
height: 100%;
}
@media (min-width: 768px) {
#container {
min-width: 600px;
min-height: 370px;
max-width: 1200px;
max-height: 860px;
padding: 36px;
}
}
@media (max-width: 767px) {
#container {
width: 100%;
height: 100%;
padding: 0;
}
}
</style>
+256 -98
View File
@@ -1,24 +1,52 @@
<script setup>
import { router } from '@/router';
import axios from 'axios';
import { marked } from 'marked';
import { ref } from 'vue';
import { defineProps } from 'vue';
marked.setOptions({
breaks: true
});
const props = defineProps({
chatboxMode: {
type: Boolean,
default: false
}
});
</script>
<template>
<v-card class="chat-page-card">
<v-card-text class="chat-page-container">
<div class="chat-layout">
<div class="sidebar-panel">
<div style="padding: 16px; padding-top: 8px;">
<v-btn variant="elevated" rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
prepend-icon="mdi-plus">创建对话</v-btn>
<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="props.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 class="conversations-container">
<div style="padding: 16px; padding-top: 8px;">
<v-btn rounded="lg" class="new-chat-btn" @click="newC" :disabled="!currCid"
v-if="!sidebarCollapsed" prepend-icon="mdi-plus">创建对话</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="newC" :disabled="!currCid" v-if="sidebarCollapsed"
elevation="0"></v-btn>
</div>
<div style="overflow-y: auto;" :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">
@@ -27,13 +55,15 @@ marked.setOptions({
<template v-slot:prepend>
<v-icon size="small" icon="mdi-message-text-outline"></v-icon>
</template>
<v-list-item-title class="conversation-title">{{ item.title || '新对话'}}</v-list-item-title>
<v-list-item-subtitle class="timestamp">{{ formatDate(item.updated_at)
<v-list-item-title v-if="!sidebarCollapsed" class="conversation-title">{{ item.title
|| '新对话' }}</v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed" class="timestamp">{{
formatDate(item.updated_at)
}}</v-list-item-subtitle>
<template 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-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>
@@ -42,12 +72,14 @@ marked.setOptions({
<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">暂无对话历史</div>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded">
暂无对话历史</div>
</div>
</v-fade-transition>
</div>
<div class="sidebar-footer">
<div style="padding: 16px; padding-bottom: 0px;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed">
<div class="sidebar-section-title">
系统状态
</div>
@@ -58,7 +90,7 @@ marked.setOptions({
<v-icon :icon="status?.llm_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
LLM 服务
<span>LLM 服务</span>
</v-chip>
<v-chip class="status-chip" :color="status?.stt_enabled ? 'success' : 'grey-lighten-2'"
@@ -67,7 +99,7 @@ marked.setOptions({
<v-icon :icon="status?.stt_enabled ? 'mdi-check-circle' : 'mdi-alert-circle'"
size="x-small"></v-icon>
</template>
语音转文本
<span>语音转文本</span>
</v-chip>
</div>
@@ -81,6 +113,20 @@ marked.setOptions({
<!-- 右侧聊天内容区域 -->
<div class="chat-content-panel">
<div v-if="currCid && getCurrentConversation" class="conversation-header fade-in">
<div class="conversation-header-content">
<h2 class="conversation-header-title">{{ getCurrentConversation.title || '新对话' }}</h2>
<div class="conversation-header-time">{{ formatDate(getCurrentConversation.updated_at) }}</div>
</div>
<div class="conversation-header-actions">
<!-- router推送到 /chatbox -->
<v-icon @click="router.push('/chatbox')" v-if="!props.chatboxMode"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</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">
@@ -200,21 +246,14 @@ marked.setOptions({
</div>
</v-card-text>
</v-card>
<!-- 编辑对话标题对话框 -->
<v-dialog v-model="editTitleDialog" max-width="400">
<v-card>
<v-card-title class="dialog-title">编辑对话标题</v-card-title>
<v-card-text>
<v-text-field
v-model="editingTitle"
label="对话标题"
variant="outlined"
hide-details
class="mt-2"
@keyup.enter="saveTitle"
autofocus
/>
<v-text-field v-model="editingTitle" label="对话标题" variant="outlined" hide-details class="mt-2"
@keyup.enter="saveTitle" autofocus />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
@@ -258,11 +297,26 @@ export default {
ctrlKeyLongPressThreshold: 300, // 长按阈值,单位毫秒
mediaCache: {}, // Add a cache to store media blobs
// 添加对话标题编辑相关变量
editTitleDialog: false,
editingTitle: '',
editingCid: '',
// 侧边栏折叠状态
sidebarCollapsed: false,
sidebarHovered: false,
sidebarHoverTimer: null,
sidebarHoverExpanded: false,
sidebarHoverDelay: 100, // 悬停延迟,单位毫秒
}
},
computed: {
// Get the current conversation from the conversations array
getCurrentConversation() {
if (!this.currCid) return null;
return this.conversations.find(c => c.cid === this.currCid);
}
},
@@ -281,6 +335,12 @@ export default {
// 添加keyup事件监听
document.addEventListener('keyup', this.handleInputKeyUp);
// 从 localStorage 获取侧边栏折叠状态
const savedCollapseState = localStorage.getItem('sidebarCollapsed');
if (savedCollapseState !== null) {
this.sidebarCollapsed = JSON.parse(savedCollapseState);
}
},
beforeUnmount() {
@@ -292,40 +352,86 @@ export default {
// 移除keyup事件监听
document.removeEventListener('keyup', this.handleInputKeyUp);
// 清除悬停定时器
if (this.sidebarHoverTimer) {
clearTimeout(this.sidebarHoverTimer);
}
// Cleanup blob URLs
this.cleanupMediaCache();
},
methods: {
// 切换侧边栏折叠状态
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);
});
.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];
@@ -336,7 +442,7 @@ export default {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
this.mediaCache[filename] = blobUrl;
return blobUrl;
@@ -671,15 +777,15 @@ export default {
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);
});
.then(response => {
this.prompt = '';
this.stagedImagesName = [];
this.stagedAudioUrl = "";
this.loadingChat = false;
})
.catch(err => {
console.error(err);
});
},
scrollToBottom() {
this.$nextTick(() => {
@@ -773,29 +879,43 @@ export default {
}
}
/* 添加淡入动画 */
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeInContent 0.2s ease-in forwards;
}
/* 聊天页面布局 */
/* todo: 聊天页面背景颜色有问题 */
.chat-page-card {
margin-bottom: 16px;
width: 100%;
height: 100%;
border-radius: 12px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05) !important;
}
.chat-page-container {
width: 100%;
height: calc(100vh - 120px);
height: 100%;
max-height: calc(100vh - 120px);
padding: 0;
}
.chat-layout {
height: 100%;
display: flex;
gap: 24px;
}
/* 侧边栏样式 - 优化版 */
.sidebar-panel {
max-width: 270px;
min-width: 240px;
@@ -806,57 +926,34 @@ export default {
background-color: var(--v-theme-containerBg);
height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
/* 防止内容溢出 */
}
.sidebar-header {
padding: 16px;
/* 侧边栏折叠状态 */
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
.conversations-container {
flex-grow: 1;
overflow-y: auto;
padding: 16px;
/* 当悬停展开时 */
.sidebar-collapsed.sidebar-hovered {
max-width: 270px;
min-width: 240px;
transition: all 0.3s ease;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.04);
/* 侧边栏折叠按钮 */
.sidebar-collapse-btn-container {
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
.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;
}
.new-chat-btn {
width: 100%;
background-color: #673ab7 !important;
color: white !important;
font-weight: 500;
box-shadow: 0 2px 8px rgba(103, 58, 183, 0.25) !important;
transition: all 0.2s ease;
text-transform: none;
letter-spacing: 0.25px;
}
.new-chat-btn:hover {
background-color: #7e57c2 !important;
box-shadow: 0 4px 12px rgba(103, 58, 183, 0.3) !important;
transform: translateY(-1px);
}
.conversation-list-card {
border-radius: 8px;
box-shadow: none !important;
background-color: var(--v-theme-containerBg);
}
.conversation-list {
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
@@ -869,7 +966,8 @@ export default {
height: auto !important;
min-height: 56px;
padding: 8px 12px !important;
position: relative; /* 确保相对定位,便于添加编辑按钮 */
position: relative;
/* 确保相对定位,便于添加编辑按钮 */
}
.conversation-item:hover {
@@ -881,12 +979,25 @@ export default {
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;
}
.status-chips {
@@ -894,6 +1005,7 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
transition: opacity 0.25s ease;
}
.status-chip {
@@ -911,6 +1023,7 @@ export default {
letter-spacing: 0.25px;
font-size: 12px;
line-height: 1.2em;
transition: opacity 0.25s ease;
}
.delete-chat-btn:hover {
@@ -930,6 +1043,7 @@ export default {
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
/* 聊天内容区域 */
@@ -1230,4 +1344,48 @@ export default {
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>