1、实现页面:

2、页面布局部分:
<template>
<div class="chat-container">
<!-- 左侧聊天会话列表 -->
<div class="chat-sessions">
<!-- 顶部标题栏 -->
<div class="header">
<img :src="zspng" alt="Logo" class="logo" />
<h1 class="title">社工助手测试</h1>
</div>
<!-- 开启新会话按钮 -->
<el-button type="primary" @click="handleNewChat" class="new-chat-button">
<template #icon>
<el-icon><Plus /></el-icon>
</template>
开启新会话
</el-button>
<!-- 会话历史记录列表 -->
<div class="session-list" ref="sessionContainer">
<div
v-for="(session, index) in sessions"
:key="index"
class="session-item"
@click="selectSession(session,index)"
:class="{ 'active-session': activeSessionIndex === index }"
>
{{ session.name }}
<el-button
type="text"
class="delete-button"
@click.stop="openRemoveSession(index)"
>
<template #icon>
<el-icon style="font-size: larger;"><Delete /></el-icon>
</template>
</el-button>
</div>
</div>
<el-button
v-if="sessions.length > 0"
type="text"
class="text-hover"
@click="getHistorySessions"
>
显示更多
</el-button>
</div>
<!-- 右侧聊天窗口 -->
<div class="chat-window">
<div class="chat-messages" ref="chatContainer">
<!-- 消息列表 -->
<div v-for="(msg, index) in messages" :key="index" class="message-wrapper">
<!-- AI消息(左侧) -->
<div v-if="!msg.isUser" class="ai-message">
<div class="avatar-container">
<img :src="zspng" alt="AI Avatar" class="avatar" />
</div>
<div class="bubble-wrapper">
<div class="dialog-arrow arrow-left"></div>
<div class="bubble bubble-left">
<div v-dompurify-html="msg.content"></div>
</div>
<div class="timestamp-external">
{{ msg.time }}
</div>
</div>
</div>
<!-- 用户消息(右侧) -->
<div v-else class="user-message">
<div class="bubble-wrapper">
<div class="dialog-arrow arrow-right"></div>
<div class="bubble bubble-right">
<div v-dompurify-html="msg.content"></div>
</div>
</div>
<div class="avatar-container">
<img :src="headPic == null ? txpng : headPic" alt="User Avatar" class="avatar" />
</div>
</div>
</div>
</div>
<!-- 右侧下方聊天内容输入框 -->
<div class="chat-input">
<el-input v-model="newMessage"
type="textarea"
:maxlength="2000"
placeholder="请输入您的问题..."
show-word-limit
resize="none"
class="custom-input"
@keyup.enter="sendMessage">
</el-input>
<el-button
type="primary"
@click="sendMessage"
class="send-button"
>
<template #icon>
<el-icon><Position /></el-icon>
</template>
发送
</el-button>
</div>
</div>
<el-dialog
v-model="dialogRemove"
title="确定删除"
width="18%"
>
<span>您确认删除吗?</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogRemove = false">取 消</el-button>
<el-button type="primary" @click="removeSession">
确 定
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
3、发送消息部分:
const globalConstants = inject('globalConstants');
// 新消息输入框内容
const newMessage = ref('')
const isLoading = ref(false)
// 当前AI流式响应内容
const currentAIResponse = ref('')
// 对话内容容器,用于滚动
const chatContainer = ref(null)
// 会话ID
let conversationId = ref(null)
// SSE连接实例
let eventSource = ref(null)
// 消息数据
const messages = ref([]);
const username = ref('zym');
const headPic = ref(null);
// 发送消息
const sendMessage = () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
if (!newMessage.value.trim() || isLoading.value) {
ElMessage({ type: 'warning', message: '输入不能为空!' })
return
}
// 1. 添加用户消息到历史记录
const userMessage = newMessage.value
messages.value.push({ isUser: true, content: userMessage })
// 2. 初始化状态
isLoading.value = true
currentAIResponse.value = ''
newMessage.value = '' // 清空输入框
eventSource.value = new SSE(globalConstants.API_URL+'/chat/chat', {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
payload: JSON.stringify({
query:userMessage,
userId: username.value,
conversationId: conversationId.value,
})
})
// 4. 处理流式消息
eventSource.value.onmessage = (event) => {
// console.log(event.data);
// 累加AI响应内容
const data = JSON.parse(event.data)
if(data.answer != undefined){
currentAIResponse.value += data.answer
}
// 将提问问题插入会话列表
if(conversationId.value == undefined){
let se = {
id: data.conversation_id,
name: userMessage.length > 18 ? userMessage.substring(0, 18) + '...' : userMessage,
created_at: null,
updated_at: null,
introduction: null,
status: null,
inputs: null
}
sessions.value.splice(0,0,se);
}
// 继续上次问题提问
conversationId.value = data.conversation_id
// console.log(data.conversation_id);
let time = "";
// 如果到了消息末尾,获取消息时间戳
if(data.event === 'message_end'){
time = formatDate(data.created_at)
}
// 更新对话历史(实时展示)
if (messages.value.length > 0 && !messages.value[messages.value.length - 1].isUser) {
// 更新最后一条AI消息
messages.value[messages.value.length - 1].content = md.render(currentAIResponse.value)
messages.value[messages.value.length - 1].time = time
} else {
// 添加新的AI消息
messages.value.push({ isUser: false, content: md.render(currentAIResponse.value), time: time})
}
// 滚动到底部(确保最新消息可见)
nextTick(() => scrollToBottom())
isLoading.value = false
}
// 5. 处理连接关闭
eventSource.value.onclose = () => {
isLoading.value = false
conversationId.value = null
eventSource.value = null
}
// 6. 处理连接错误
eventSource.value.onerror = (error) => {
console.error('SSE 连接错误:', error)
messages.value.push({ isUser: false, content: '[连接超时,请刷新页面]' })
isLoading.value = false
conversationId.value = null
eventSource.value.close()
}
}
// 滚动到最新消息(确保DOM更新后执行)
const scrollToBottom = () => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
4、展示左侧会话列表:
// 会话列表
const sessions = ref<ChatSession>([]);
// 当前激活的会话索引
const activeSessionIndex = ref<number | String>(null);
// 是否有下一页
const hasMore = ref<boolean>(false);
// 当前页最后面一条记录的 ID
let lastId = ref(null);
const removeSessionIndex = ref<number | String>(null);
const sessionContainer = ref(null)
// 加载会话列表
const loadSessions = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
const response = await request({
url: '/chat/conversations',
method: 'POST',
data: JSON.stringify({
lastId: null,
user: username.value,
limit: 15,
})
})
if(response){
// console.log(response);
hasMore.value = response.has_more
const data = response.data
sessions.value.push(...data)
const id = data.slice(-1)[0].id
lastId.value = id;
}
};
// 显示更多会话历史
const getHistorySessions = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
if(!hasMore.value){
ElMessage({ type: 'warning', message: '没有更多历史记录了!' })
return;
}
const response = await request({
url: '/chat/conversations',
method: 'POST',
data: JSON.stringify({
lastId: lastId.value,
user: username.value,
limit: 15,
code: code.value
})
})
if(response){
hasMore.value = response.has_more
const data = response.data
sessions.value.push(...data)
const id = data.slice(-1)[0].id
lastId.value = id;
// 滚动到底部
nextTick(() => scrollToBottom_2())
}
}
const scrollToBottom_2 = () => {
if (sessionContainer.value) {
sessionContainer.value.scrollTop = sessionContainer.value.scrollHeight
}
}
5、选择会话,展现单个会话历史:
// 选择会话
const selectSession = async (session,index) => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
activeSessionIndex.value = index;
conversationId.value = session.id
const response = await request({
url: '/chat/messages',
method: 'POST',
data: JSON.stringify({
user: username.value,
conversationId: conversationId.value,
})
})
if(response){
// console.log(response);
messages.value = [];
const datas = response;
for(let data of datas){
let time = formatDate(data.created_at);
messages.value.push({ isUser: true, content: md.render(data.query)})
messages.value.push({ isUser: false, content: md.render(data.answer), time: time})
conversationId.value = data.conversation_id
}
// 滚动到底部(确保最新消息可见)
nextTick(() => scrollToBottom())
}
};
6、删除会话:
const dialogRemove = ref(false)
const openRemoveSession = (index) => {
dialogRemove.value = true;
removeSessionIndex.value = index
};
// 删除会话
const removeSession = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
const session = sessions.value[removeSessionIndex.value]
// console.log(session.id);
const response = await request({
url: '/chat/deleteConversation',
method: 'POST',
data: JSON.stringify({
user: username.value,
conversationId: session.id,
})
})
if(response){
// console.log(response);
if(response === 'success'){
sessions.value.splice(removeSessionIndex.value, 1);
if (activeSessionIndex.value === removeSessionIndex.value) {
activeSessionIndex.value = null;
messages.value = [];
conversationId.value = null;
}
dialogRemove.value = false;
ElMessage({ type: 'success', message: '删除成功' })
}else{
ElMessage({ type: 'error', message: '删除失败' })
}
}
}
7、样式css:
<style scoped lang="scss">
.chat-container {
display: flex;
height: 98vh;
}
.chat-sessions {
width: 18%;
border-right: 1px solid #ccc;
background-color: #fff;
// padding: 10px;
padding-right: 10px;
}
.chat-window {
width: 82%;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
overflow-y: auto;
// padding: 10px;
background-color: #f5f7fa;
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.3);
}
.chat-input {
display: flex;
padding: 25px;
}
/* 输入框样式优化 */
.custom-input {
flex: 1;
transition: all 0.3s ease;
}
.custom-input :deep(.el-textarea__inner) {
padding: 12px 16px;
padding-right: 120px; /* 为字数限制留出空间 */
font-size: 14px;
border-radius: 12px;
border: 1px solid #e4e7ed;
background: #f9fafb;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
line-height: 1.6;
min-height: 48px;
height: 85px;
resize: none;
}
.custom-input :deep(.el-textarea__inner:hover) {
background: #fff;
border-color: #c0c4cc;
}
.custom-input :deep(.el-textarea__inner:focus) {
background: #fff;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
/* 字数限制样式 */
.custom-input :deep(.el-input__count) {
position: absolute;
right: 12px;
bottom: 8px;
background: transparent;
font-size: 12px;
color: #909399;
padding: 0;
height: auto;
line-height: 1;
margin: 0;
}
/* 发送按钮样式 */
.send-button {
margin-left: 10px;
margin-top: 16px;
padding: 0 24px;
font-size: 14px;
border-radius: 10px;
height: 50px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.send-button:not(:disabled) {
background: linear-gradient(135deg, #409eff, #3a8ee6);
border: none;
}
.send-button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.send-button:not(:disabled):active {
transform: translateY(0);
}
.send-button :deep(.el-icon) {
font-size: 16px;
margin-right: 4px;
vertical-align: -2px;
}
// 消息气泡
/* 消息包装器 */
.message-wrapper {
margin-bottom: 20px;
display: flex;
}
/* AI消息(左侧) */
.ai-message {
display: flex;
justify-content: flex-start;
width: 100%;
}
/* 用户消息(右侧) */
.user-message {
display: flex;
justify-content: flex-end;
width: 100%;
}
/* 头像容器 */
.avatar-container {
flex-shrink: 0;
margin: 0 12px;
}
/* 头像样式 */
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 气泡包装器 */
.bubble-wrapper {
position: relative;
flex-direction: column;
max-width: 70%;
display: flex;
// align-items: center;
}
/* 气泡通用样式 */
.bubble {
// padding: 12px 16px;
// border-radius: 18px;
// line-height: 0.9;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
// white-space: pre-wrap;
// word-break: break-word;
// transition: transform 0.3s ease, box-shadow 0.3s ease;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
-webkit-text-size-adjust: 100%;
margin: 0;
font-weight: 400;
line-height: 1.9;
word-wrap: break-word;
word-break: break-word;
-webkit-user-select: text;
user-select: text;
}
.bubble:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
/* AI气泡(左侧) */
.bubble-left {
background: #ffffff;
border-radius: 0 18px 18px 18px;
color: #24292F;
margin-left: 8px;
}
/* 用户气泡(右侧) */
.bubble-right {
background: #4e8cff;
border-radius: 18px 0 18px 18px;
color: white;
margin-right: 8px;
line-height: 1.5;
}
/* 气泡箭头 */
.dialog-arrow {
position: absolute;
width: 12px;
height: 12px;
background: inherit;
z-index: -1;
}
/* AI箭头(左侧) */
.arrow-left {
left: -6px;
transform: rotate(45deg);
background: #ffffff;
box-shadow: -2px 2px 2px rgba(0, 0, 0, 0.05);
}
/* 用户箭头(右侧) */
.arrow-right {
right: -6px;
transform: rotate(45deg);
background: #4e8cff;
box-shadow: 2px -2px 2px rgba(0, 0, 0, 0.05);
}
/* 外部时间戳 */
.timestamp-external {
font-size: 12px;
color: #999;
margin-top: 4px; /* 气泡与时间戳间距 */
padding: 0 5px;
margin-left: auto;
margin-right: 0;
}
ul, ol {
list-style: none;
padding-left: 1.5em;
}
li::before {
content: "•";
position: absolute;
left: 0;
}
// 会话历史
.header {
display: flex;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
height: 6%;
}
.logo {
width: 50px;
height: 50px;
margin-right: 10px;
}
.title {
font-size: 22px;
margin-left: 10px;
}
.new-chat-button {
width: 100%;
margin-bottom: 10px;
}
.session-list {
list-style: none;
padding: 0;
max-height: 83%; /* 设置一个固定高度 */
overflow-y: auto; /* 允许垂直滚动 */
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar {
width: 6px; /* 设置滚动条的宽度 */
}
::-webkit-scrollbar-thumb {
background-color: #ccc; /* 淡灰色 */
border-radius: 10px; /* 圆角,使滚动条更柔和 */
}
::-webkit-scrollbar-button {
display: none;
}
.session-item {
padding: 10px;
border-bottom: 1px solid #eee;
border-radius:6px;
cursor: pointer;
position: relative;
color: #24292F;
}
.session-item:hover .delete-button {
display: block;
}
.delete-button {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: none;
}
.active-session {
background-color: #ecf5ff;
}
.text-hover:hover {
background-color: #EBEEF5 !important; /* 淡灰色背景 */
border-radius: 4px; /* 可选:圆角优化 */
}
.text-hover:focus {
background-color: transparent !important; /* 移除点击后背景残留 */
}
.timestamp {
font-size: 11px;
color: rgba(0, 0, 0, 0.6);
text-align: right;
margin-top: 6px;
padding-top: 4px;
border-top: 1px dashed rgba(0,0,0,0.1);
}
</style>
8、整体代码:
<template>
<div class="chat-container">
<!-- 左侧聊天会话列表 -->
<div class="chat-sessions">
<!-- 顶部标题栏 -->
<div class="header">
<img :src="zspng" alt="Logo" class="logo" />
<h1 class="title">社工助手测试</h1>
</div>
<!-- 开启新会话按钮 -->
<el-button type="primary" @click="handleNewChat" class="new-chat-button">
<template #icon>
<el-icon><Plus /></el-icon>
</template>
开启新会话
</el-button>
<!-- 会话历史记录列表 -->
<div class="session-list" ref="sessionContainer">
<div
v-for="(session, index) in sessions"
:key="index"
class="session-item"
@click="selectSession(session,index)"
:class="{ 'active-session': activeSessionIndex === index }"
>
{{ session.name }}
<el-button
type="text"
class="delete-button"
@click.stop="openRemoveSession(index)"
>
<template #icon>
<el-icon style="font-size: larger;"><Delete /></el-icon>
</template>
</el-button>
</div>
</div>
<el-button
v-if="sessions.length > 0"
type="text"
class="text-hover"
@click="getHistorySessions"
>
显示更多
</el-button>
</div>
<!-- 右侧聊天窗口 -->
<div class="chat-window">
<div class="chat-messages" ref="chatContainer">
<!-- 消息列表 -->
<div v-for="(msg, index) in messages" :key="index" class="message-wrapper">
<!-- AI消息(左侧) -->
<div v-if="!msg.isUser" class="ai-message">
<div class="avatar-container">
<img :src="zspng" alt="AI Avatar" class="avatar" />
</div>
<div class="bubble-wrapper">
<div class="dialog-arrow arrow-left"></div>
<div class="bubble bubble-left">
<div v-dompurify-html="msg.content"></div>
</div>
<div class="timestamp-external">
{{ msg.time }}
</div>
</div>
</div>
<!-- 用户消息(右侧) -->
<div v-else class="user-message">
<div class="bubble-wrapper">
<div class="dialog-arrow arrow-right"></div>
<div class="bubble bubble-right">
<div v-dompurify-html="msg.content"></div>
</div>
</div>
<div class="avatar-container">
<img :src="headPic == null ? txpng : headPic" alt="User Avatar" class="avatar" />
</div>
</div>
</div>
</div>
<!-- 右侧下方聊天内容输入框 -->
<div class="chat-input">
<el-input v-model="newMessage"
type="textarea"
:maxlength="2000"
placeholder="请输入您的问题..."
show-word-limit
resize="none"
class="custom-input"
@keyup.enter="sendMessage">
</el-input>
<el-button
type="primary"
@click="sendMessage"
class="send-button"
>
<template #icon>
<el-icon><Position /></el-icon>
</template>
发送
</el-button>
</div>
</div>
<el-dialog
v-model="dialogRemove"
title="确定删除"
width="18%"
>
<span>您确认删除吗?</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogRemove = false">取 消</el-button>
<el-button type="primary" @click="removeSession">
确 定
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {nextTick, onMounted, ref} from 'vue';
import { ElMessage } from 'element-plus'
import request from "@/utils/request";
import markdownIt from 'markdown-it'
import { SSE } from 'sse.js'
import { v4 as uuidv4 } from 'uuid'
import { ChatSession } from '@/types/ChatSession'
import zspng from '@/assets/static/zs.png'
import txpng from '@/assets/static/tx.png'
import { inject } from 'vue';
import 'highlight.js/styles/atom-one-dark.css';
import hljs from 'highlight.js';
const md = new markdownIt({
html: false, // 在源码中启用 HTML 标签
xhtmlOut: true, // 使用 '/' 来闭合单标签。这个选项只对完全的 CommonMark 模式兼容。
breaks: true, // 转换段落里的 '\n' 到 <br>。
langPrefix: 'language-', // 给围栏代码块的 CSS 语言前缀。对于额外的高亮代码非常有用。
linkify: false, // 将类似 URL 的文本自动转换为链接。
typographer: true, // 启用一些语言中立的替换 + 引号美化
quotes: '“”‘’', // 双 + 单引号替换对,当 typographer 启用时。或智能引号等,可以是String或 Array。
highlight: function (str, lang) {// 代码高亮
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (err) {
console.error("高亮错误:", err);
}
}
// 默认处理
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
})
const globalConstants = inject('globalConstants');
// 新消息输入框内容
const newMessage = ref('')
const isLoading = ref(false)
// 当前AI流式响应内容
const currentAIResponse = ref('')
// 对话内容容器,用于滚动
const chatContainer = ref(null)
// 会话ID
let conversationId = ref(null)
// SSE连接实例
let eventSource = ref(null)
// 消息数据
const messages = ref([]);
const username = ref('zym');
const headPic = ref(null);
// 发送消息
const sendMessage = () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
if (!newMessage.value.trim() || isLoading.value) {
ElMessage({ type: 'warning', message: '输入不能为空!' })
return
}
// 1. 添加用户消息到历史记录
const userMessage = newMessage.value
messages.value.push({ isUser: true, content: userMessage })
// 2. 初始化状态
isLoading.value = true
currentAIResponse.value = ''
newMessage.value = '' // 清空输入框
eventSource.value = new SSE(globalConstants.API_URL+'/chat/chat', {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
payload: JSON.stringify({
query:userMessage,
userId: username.value,
conversationId: conversationId.value,
})
})
// 4. 处理流式消息
eventSource.value.onmessage = (event) => {
// console.log(event.data);
// 累加AI响应内容
const data = JSON.parse(event.data)
if(data.answer != undefined){
currentAIResponse.value += data.answer
}
// 将提问问题插入会话列表
if(conversationId.value == undefined){
let se = {
id: data.conversation_id,
name: userMessage.length > 18 ? userMessage.substring(0, 18) + '...' : userMessage,
created_at: null,
updated_at: null,
introduction: null,
status: null,
inputs: null
}
sessions.value.splice(0,0,se);
}
// 继续上次问题提问
conversationId.value = data.conversation_id
// console.log(data.conversation_id);
let time = "";
// 如果到了消息末尾,获取消息时间戳
if(data.event === 'message_end'){
time = formatDate(data.created_at)
}
// 更新对话历史(实时展示)
if (messages.value.length > 0 && !messages.value[messages.value.length - 1].isUser) {
// 更新最后一条AI消息
messages.value[messages.value.length - 1].content = md.render(currentAIResponse.value)
messages.value[messages.value.length - 1].time = time
} else {
// 添加新的AI消息
messages.value.push({ isUser: false, content: md.render(currentAIResponse.value), time: time})
}
// 滚动到底部(确保最新消息可见)
nextTick(() => scrollToBottom())
isLoading.value = false
}
// 5. 处理连接关闭
eventSource.value.onclose = () => {
isLoading.value = false
conversationId.value = null
eventSource.value = null
}
// 6. 处理连接错误
eventSource.value.onerror = (error) => {
console.error('SSE 连接错误:', error)
messages.value.push({ isUser: false, content: '[连接超时,请刷新页面]' })
isLoading.value = false
conversationId.value = null
eventSource.value.close()
}
}
// 滚动到最新消息(确保DOM更新后执行)
const scrollToBottom = () => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
const formatDate = (timestamp) => {
// 将秒转换为毫秒
const date = new Date(timestamp * 1000);
// 格式化日期和时间,例如:yyyy-mm-dd hh:mm
const year = date.getFullYear();
const month = ('0' + (date.getMonth() + 1)).slice(-2); // 月份是从0开始的
const day = ('0' + date.getDate()).slice(-2);
const hours = ('0' + date.getHours()).slice(-2);
const minutes = ('0' + date.getMinutes()).slice(-2);
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 会话列表
const sessions = ref<ChatSession>([]);
// 当前激活的会话索引
const activeSessionIndex = ref<number | String>(null);
// 是否有下一页
const hasMore = ref<boolean>(false);
// 当前页最后面一条记录的 ID
let lastId = ref(null);
const removeSessionIndex = ref<number | String>(null);
const sessionContainer = ref(null)
// 加载会话列表
const loadSessions = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
const response = await request({
url: '/chat/conversations',
method: 'POST',
data: JSON.stringify({
lastId: null,
user: username.value,
limit: 15,
})
})
if(response){
// console.log(response);
hasMore.value = response.has_more
const data = response.data
sessions.value.push(...data)
const id = data.slice(-1)[0].id
lastId.value = id;
}
};
// 显示更多会话历史
const getHistorySessions = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
if(!hasMore.value){
ElMessage({ type: 'warning', message: '没有更多历史记录了!' })
return;
}
const response = await request({
url: '/chat/conversations',
method: 'POST',
data: JSON.stringify({
lastId: lastId.value,
user: username.value,
limit: 15,
code: code.value
})
})
if(response){
hasMore.value = response.has_more
const data = response.data
sessions.value.push(...data)
const id = data.slice(-1)[0].id
lastId.value = id;
// 滚动到底部
nextTick(() => scrollToBottom_2())
}
}
const scrollToBottom_2 = () => {
if (sessionContainer.value) {
sessionContainer.value.scrollTop = sessionContainer.value.scrollHeight
}
}
// 选择会话
const selectSession = async (session,index) => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
activeSessionIndex.value = index;
conversationId.value = session.id
const response = await request({
url: '/chat/messages',
method: 'POST',
data: JSON.stringify({
user: username.value,
conversationId: conversationId.value,
})
})
if(response){
// console.log(response);
messages.value = [];
const datas = response;
for(let data of datas){
let time = formatDate(data.created_at);
messages.value.push({ isUser: true, content: md.render(data.query)})
messages.value.push({ isUser: false, content: md.render(data.answer), time: time})
conversationId.value = data.conversation_id
}
// 滚动到底部(确保最新消息可见)
nextTick(() => scrollToBottom())
}
};
const dialogRemove = ref(false)
const openRemoveSession = (index) => {
dialogRemove.value = true;
removeSessionIndex.value = index
};
// 删除会话
const removeSession = async () => {
if (username.value == undefined) {
ElMessage({ type: 'warning', message: '用户不能为空!' })
return
}
const session = sessions.value[removeSessionIndex.value]
// console.log(session.id);
const response = await request({
url: '/chat/deleteConversation',
method: 'POST',
data: JSON.stringify({
user: username.value,
conversationId: session.id,
})
})
if(response){
// console.log(response);
if(response === 'success'){
sessions.value.splice(removeSessionIndex.value, 1);
if (activeSessionIndex.value === removeSessionIndex.value) {
activeSessionIndex.value = null;
messages.value = [];
conversationId.value = null;
}
dialogRemove.value = false;
ElMessage({ type: 'success', message: '删除成功' })
}else{
ElMessage({ type: 'error', message: '删除失败' })
}
}
}
// 开启新会话
const handleNewChat = () => {
messages.value = [];
conversationId.value = null;
activeSessionIndex.value = null;
};
// 组件挂载时加载会话列表
onMounted(() => {
loadSessions();
})
</script>
<style scoped lang="scss">
.chat-container {
display: flex;
height: 98vh;
}
.chat-sessions {
width: 18%;
border-right: 1px solid #ccc;
background-color: #fff;
// padding: 10px;
padding-right: 10px;
}
.chat-window {
width: 82%;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
overflow-y: auto;
// padding: 10px;
background-color: #f5f7fa;
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.3);
}
.chat-input {
display: flex;
padding: 25px;
}
/* 输入框样式优化 */
.custom-input {
flex: 1;
transition: all 0.3s ease;
}
.custom-input :deep(.el-textarea__inner) {
padding: 12px 16px;
padding-right: 120px; /* 为字数限制留出空间 */
font-size: 14px;
border-radius: 12px;
border: 1px solid #e4e7ed;
background: #f9fafb;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.02);
transition: all 0.3s ease;
line-height: 1.6;
min-height: 48px;
height: 85px;
resize: none;
}
.custom-input :deep(.el-textarea__inner:hover) {
background: #fff;
border-color: #c0c4cc;
}
.custom-input :deep(.el-textarea__inner:focus) {
background: #fff;
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}
/* 字数限制样式 */
.custom-input :deep(.el-input__count) {
position: absolute;
right: 12px;
bottom: 8px;
background: transparent;
font-size: 12px;
color: #909399;
padding: 0;
height: auto;
line-height: 1;
margin: 0;
}
/* 发送按钮样式 */
.send-button {
margin-left: 10px;
margin-top: 16px;
padding: 0 24px;
font-size: 14px;
border-radius: 10px;
height: 50px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.send-button:not(:disabled) {
background: linear-gradient(135deg, #409eff, #3a8ee6);
border: none;
}
.send-button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.send-button:not(:disabled):active {
transform: translateY(0);
}
.send-button :deep(.el-icon) {
font-size: 16px;
margin-right: 4px;
vertical-align: -2px;
}
// 消息气泡
/* 消息包装器 */
.message-wrapper {
margin-bottom: 20px;
display: flex;
}
/* AI消息(左侧) */
.ai-message {
display: flex;
justify-content: flex-start;
width: 100%;
}
/* 用户消息(右侧) */
.user-message {
display: flex;
justify-content: flex-end;
width: 100%;
}
/* 头像容器 */
.avatar-container {
flex-shrink: 0;
margin: 0 12px;
}
/* 头像样式 */
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 气泡包装器 */
.bubble-wrapper {
position: relative;
flex-direction: column;
max-width: 70%;
display: flex;
// align-items: center;
}
/* 气泡通用样式 */
.bubble {
// padding: 12px 16px;
// border-radius: 18px;
// line-height: 0.9;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
// white-space: pre-wrap;
// word-break: break-word;
// transition: transform 0.3s ease, box-shadow 0.3s ease;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
-webkit-text-size-adjust: 100%;
margin: 0;
font-weight: 400;
line-height: 1.9;
word-wrap: break-word;
word-break: break-word;
-webkit-user-select: text;
user-select: text;
}
.bubble:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
/* AI气泡(左侧) */
.bubble-left {
background: #ffffff;
border-radius: 0 18px 18px 18px;
color: #24292F;
margin-left: 8px;
}
/* 用户气泡(右侧) */
.bubble-right {
background: #4e8cff;
border-radius: 18px 0 18px 18px;
color: white;
margin-right: 8px;
line-height: 1.5;
}
/* 气泡箭头 */
.dialog-arrow {
position: absolute;
width: 12px;
height: 12px;
background: inherit;
z-index: -1;
}
/* AI箭头(左侧) */
.arrow-left {
left: -6px;
transform: rotate(45deg);
background: #ffffff;
box-shadow: -2px 2px 2px rgba(0, 0, 0, 0.05);
}
/* 用户箭头(右侧) */
.arrow-right {
right: -6px;
transform: rotate(45deg);
background: #4e8cff;
box-shadow: 2px -2px 2px rgba(0, 0, 0, 0.05);
}
/* 外部时间戳 */
.timestamp-external {
font-size: 12px;
color: #999;
margin-top: 4px; /* 气泡与时间戳间距 */
padding: 0 5px;
margin-left: auto;
margin-right: 0;
}
ul, ol {
list-style: none;
padding-left: 1.5em;
}
li::before {
content: "•";
position: absolute;
left: 0;
}
// 会话历史
.header {
display: flex;
align-items: center;
margin-top: 10px;
margin-bottom: 10px;
height: 6%;
}
.logo {
width: 50px;
height: 50px;
margin-right: 10px;
}
.title {
font-size: 22px;
margin-left: 10px;
}
.new-chat-button {
width: 100%;
margin-bottom: 10px;
}
.session-list {
list-style: none;
padding: 0;
max-height: 83%; /* 设置一个固定高度 */
overflow-y: auto; /* 允许垂直滚动 */
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar {
width: 6px; /* 设置滚动条的宽度 */
}
::-webkit-scrollbar-thumb {
background-color: #ccc; /* 淡灰色 */
border-radius: 10px; /* 圆角,使滚动条更柔和 */
}
::-webkit-scrollbar-button {
display: none;
}
.session-item {
padding: 10px;
border-bottom: 1px solid #eee;
border-radius:6px;
cursor: pointer;
position: relative;
color: #24292F;
}
.session-item:hover .delete-button {
display: block;
}
.delete-button {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: none;
}
.active-session {
background-color: #ecf5ff;
}
.text-hover:hover {
background-color: #EBEEF5 !important; /* 淡灰色背景 */
border-radius: 4px; /* 可选:圆角优化 */
}
.text-hover:focus {
background-color: transparent !important; /* 移除点击后背景残留 */
}
.timestamp {
font-size: 11px;
color: rgba(0, 0, 0, 0.6);
text-align: right;
margin-top: 6px;
padding-top: 4px;
border-top: 1px dashed rgba(0,0,0,0.1);
}
</style>
前端源码Gitee:https://gitee.com/zym0908/my_dify_ui.git
我的后端源码Gitee:https://gitee.com/zym0908/my_dify_web.git
后端是参考这篇博客写的:https://blog.youkuaiyun.com/F957614265/article/details/149390083
3019

被折叠的 条评论
为什么被折叠?



