vue3 + element plus + sse 实现Dify助手聊天会话

该文章已生成可运行项目,

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

本文章已经生成可运行项目
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值