Ai智能体四:互动式 AI 聊天助手:前端实现

在现代 web 应用中,集成智能对话功能已经成为提升用户体验的重要手段之一。本文将介绍如何通过 Vue 3Element Plus 构建一个高效的 AI 聊天助手界面,并详细讲解其实现原理和功能。

1. 整体架构

该聊天界面分为 左侧边栏右侧内容区域,实现了清晰的布局结构,左侧边栏用于展示历史会话和各种功能开关,右侧内容区域用于展示当前会话的消息和输入框。系统支持以下功能:

  • 向量存储:可以开启以便访问已上传的文档内容。
  • Agent功能:允许开启自动化代理功能来增强对话能力。
  • 数据分析:用户可以上传文件进行分析,并在分析后通过向量存储访问文件内容。

此外,用户还可以查看和管理历史会话、快速开始对话、上传文件等。

2. 左侧边栏:管理与功能开关

左侧边栏包含了三个主要部分:

  • Logo与新建会话:展示应用的Logo并支持创建新会话。
  • 功能开关:包括启用向量存储、启用Agent功能以及文件上传进行数据分析的功能开关。这些功能开关都使用了 Element Plusel-switch 组件,允许用户根据需求自由开关。
  • 历史消息列表:展示用户的历史会话,用户可以点击每个会话查看之前的消息,并删除不需要的会话。

<div class="sidebar">
  <div class="sidebar-header">
    <div class="logo">
      <img src="@/assets/logo.png" alt="Logo" class="logo-img" />
      <span class="logo-text">AI助手</span>
    </div>
    <el-icon class="new-chat-icon" @click="resetChat"><Plus /></el-icon>
  </div>
  <div class="feature-toggles">
    <div class="toggle-item">
      <div class="toggle-label-group">
        <span class="toggle-label">向量存储</span>
        <el-tooltip content="开启后可以访问已上传的文档内容" placement="right">
          <el-icon class="info-icon"><InfoFilled /></el-icon>
        </el-tooltip>
      </div>
      <el-switch v-model="enableVectorStore" size="small" active-color="#409EFF" />
    </div>
    <div class="toggle-item">
      <span class="toggle-label">Agent</span>
      <el-switch v-model="enableAgent" size="small" active-color="#409EFF" />
    </div>
    <div class="toggle-item">
      <div class="toggle-label-group">
        <span class="toggle-label">数据分析</span>
        <el-tooltip content="上传文件进行分析,分析后可通过向量存储访问文件内容" placement="right">
          <el-icon class="info-icon"><InfoFilled /></el-icon>
        </el-tooltip>
      </div>
      <el-upload :auto-upload="false" :show-file-list="false" :on-change="handleEmbedding" class="upload-btn-small">
        <el-button size="small" type="primary" :loading="uploadLoading">上传文件</el-button>
      </el-upload>
    </div>
  </div>
  <div class="history-list">
    <div class="history-group">
      <div class="group-title">历史消息</div>
      <div v-for="item in conversations" :key="item.sessionId" class="chat-item" :class="{ active: currentSessionId === item.sessionId }" @click="selectSession(item)">
        <div class="chat-item-content">
          <el-icon><ChatLineRound /></el-icon>
          <span class="chat-title">{
   
   { item.name }}</span>
          <span class="chat-time">{
   
   { formatTime(item.createdTime) }}</span>
        </div>
        <div class="chat-item-actions">
          <el-icon class="delete-icon" @click.stop="handleDelete(item.sessionId)"><Delete /></el-icon>
        </div>
      </div>
    </div>
  </div>
</div>

3. 右侧内容区:展示与互动

右侧内容区展示当前选定会话的详细内容。用户可以看到与 AI 的对话消息,并通过输入框进行新一轮的对话。输入框支持文本输入和文件上传,允许用户在对话中插入文件或直接输入消息内容。

初始页面

当用户没有选择当前会话时,右侧内容区域会显示欢迎信息和热门问答,供用户快速开始与 AI 的对话。

<template v-if="!currentSessionId">
  <div class="welcome-page">
    <h1 class="welcome-title">欢迎使用小鹏AI助手</h1>
    <div class="history-qa" v-if="randomHistoryMessages.length > 0">
      <h2>热门问答</h2>
      <div class="qa-list">
        <div v-for="(qa, index) in randomHistoryMessages" :key="index" class="qa-item" @click="quickStart(qa.question)">
          <div class="qa-question">
            <el-icon><ChatLineRound /></el-icon>
            <span>{
   
   { qa.question }}</span>
          </div>
          <div class="qa-answer">{
   
   { qa.answer }}</div>
        </div>
      </div>
    </div>
  </div>
</template>
对话消息区

当用户选择了某个会话后,右侧内容区展示历史对话消息,包括用户和 AI 的消息内容。每条消息根据消息类型(如用户消息、AI 回复、系统消息)进行不同的样式渲染。

<template v-else>
  <div class="chat-messages" ref="messagesContainer">
    <div v-for="message in messages" :key="message.id" class="message-item" :class="message.type.toLowerCase()">
      <div class="message-content">{
   
   { message.textContent }}</div>
      <div class="message-time">{
   
   { formatMessageTime(message.createdTime) }}</div>
    </div>
  </div>
</template>
输入与发送

输入框允许用户在对话框内输入消息,并支持文件上传。点击发送按钮后,消息将被发送到后端,并实时显示在聊天界面。

<div class="input-section">
  <div class="input-wrapper">
    <el-input v-model="inputMessage" :placeholder="currentSessionId ? '继续对话...' : '给 AI助手 发送消息'" class="message-input" size="large" @keyup.enter="sendMessage">
      <template #suffix>
        <div class="input-actions">
          <el-upload ref="uploadRef" :auto-upload="false" :show-file-list="false" :on-change="handleFileChange" class="upload-btn">
            <el-icon :size="20"><Paperclip /></el-icon>
          </el-upload>
          <div class="send-btn" :class="{ active: canSend }" @click="sendMessage">
            <el-icon><Position /></el-icon>
          </div>
        </div>
      </template>
    </el-input>
  </div>
  <div v-if="selectedFile" class="selected-file">
    <el-tag closable @close="clearFile" class="file-tag">{
   
   { selectedFile.name }}</el-tag>
  </div>
</div>

4. 数据与状态管理

在代码中,使用了 Vue 3 的 Composition API 来管理组件的状态和响应式数据。这里的状态主要包括用户输入、当前会话、消息内容、文件上传等。

const inputMessage = ref('') // 用户输入的消息
const conversations = ref([]) // 历史会话列表
const currentSessionId = ref(null) // 当前选中的会话 ID
const messages = ref([]) // 当前会话的消息内容
const loading = ref(false) // 加载状态,用于发送消息时显示加载效果
const messagesContainer = ref(null) // 对话内容容器,用于滚动
const uploadRef = ref(null) // 文件上传组件的引用
const selectedFile = ref(null) // 已选择的文件
const eventSource = ref(null) // 用于 SSE 连接的对象
const enableVectorStore = ref(false) // 启用向量存储
const enableAgent = ref(false) // 启用 Agent
const uploadLoading = ref(false) // 文件上传时的加载状态
const randomHistoryMessages = ref([]) // 随机历史消息

5. 历史会话与消息获取

const getConversations = async () => {
  try {
    const res = await request({
      url: '/api/ai/getConversationById',
      method: 'get',
      params: { userId: userStore.userId }
    })
    if (res.success) {
      conversations.value = res.data
      // 获取随机历史消息
      await getRandomHistoryMessages()
    } else {
      ElMessage.error('获取历史会话失败')
    }
  } catch (error) {
    console.error('获取历史会话出错:', error)
    ElMessage.error('获取历史会话失败')
  }
}

getConversations 方法用于从后端获取历史会话记录,并通过 conversations 状态更新会话列表。如果获取失败,会显示错误信息。

同理,getMessages 用来获取特定会话的消息内容。

6. 消息发送与文件上传

const sendMessage = async () => {
  if ((!inputMessage.value.trim() && !selectedFile.value) || loading.value) return
  
  const content = inputMessage.value.trim()
  inputMessage.value = '' // 立即清空输入框
  loading.value = true

  try {
    let sessionId = currentSessionId.value
    
    // 如果没有当前会话,创建新会话
    if (!sessionId) {
      sessionId = await createConversation(content || selectedFile.value?.name)
      if (!sessionId) {
        loading.value = false
        return
      }
      currentSessionId.value = sessionId
      await getConversations()
    }

    // 准备消息数据
    const chatMessage = {
      sessionId: sessionId.toString(),
      textContent: content,
      type: 'user',
      medias: [],
      userId: userStore.userId
    }

    // 添加用户消息到界面
    messages.value.push({
      ...chatMessage,
      createdTime: new Date().toISOString()
    })

    // 添加AI消息占位
    const aiMessage = {
      id: (Date.now() + 1).toString(),
      type: 'assistant',
      textContent: '',
      createdTime: new Date().toISOString(),
      sessionId: sessionId.toString()
    }
    messages.value.push(aiMessage)

    await scrollToBottom()

    // 创建 FormData
    const formData = new FormData()
    formData.append('input', JSON.stringify({
      message: chatMessage,
      params: {
        enableVectorStore: enableVectorStore.value,
        enableAgent: enableAgent.value
      }
    }))
    
    // 如果有文件,添加到 FormData
    if (selectedFile.value) {
      formData.append('file', selectedFile.value)
    }

    // 关闭之前的连接
    if (eventSource.value) {
      eventSource.value.close()
    }

    // 创建 SSE 连接
    eventSource.value = new SSE('/api/ai/chat', {
      headers: {
        Accept: 'text/event-stream',
      },
      method: 'POST',
      payload: formData
    })

    eventSource.value.onmessage = (event) => {
      try {
        const responseData = JSON.parse(event.data)
        const finishReason = responseData.result?.metadata?.finishReason
        if (responseData.result?.output?.content) {
          aiMessage.textContent += responseData.result.output.content
          messages.value = [...messages.value]
          scrollToBottom()
        }
        if (finishReason && finishReason.toLowerCase() === 'stop') {
          // 保存ai的回复消息
          saveMessage({
            sessionId: aiMessage.sessionId,
            textContent: aiMessage.textContent,
            type: aiMessage.type,
            medias: []
          })
          loading.value = false
          clearFile()
          eventSource.value?.close()
        }
      } catch (error) {
        console.error('解析消息出错:', error)
      }
    }

    eventSource.value.onerror = (error) => {
      console.error('SSE 连接错误:', error)
      if (aiMessage.textContent === '') {
        aiMessage.textContent = '消息处理出错,请重试'
      }
      eventSource.value?.close()
      loading.value = false
      clearFile()
    }

    eventSource.value.onopen = () => {
      console.log('SSE 连接已建立')
    }

  } catch (error) {
    console.error('发送消息失败:', error)
    ElMessage.error('发送消息失败,请重试')
    loading.value = false
  }
}

sendMessage 是处理用户输入的核心函数。当用户输入消息并点击发送时,函数首先判断是否有有效的输入(文本或文件),然后处理消息的发送,包括创建新的会话、更新界面上的消息以及建立与后端的 SSE(服务器推送事件)连接。

SSE 连接的目的是接收 AI 模型的响应,并实时更新对话内容。AI 回复的内容通过 onmessage 事件监听器获取,并动态地将其添加到消息列表中。

7. 文件上传与处理

const handleEmbedding = async (file) => {
  uploadLoading.value = true
  try {
    const formData = new FormData()
    formData.append('file', file.raw)
    
    const res = await request({
      url: '/api/ai/embedding',
      method: 'post',
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
    
    if (res.success) {
      ElMessage.success('文件分析成功,现在可以开启向量存储来访问文件内容')
      // 自动开启向量存储
      enableVectorStore.value = true
    } else {
      ElMessage.error('文件分析失败')
    }
  } catch (error) {
    console.error('文件分析失败:', error)
    ElMessage.error('文件分析失败')
  } finally {
    uploadLoading.value = false
  }
}

handleEmbedding 方法处理文件上传。文件被上传到后端进行分析,分析后开启向量存储功能,用户可以通过此功能访问文件中的内容。上传过程使用了 FormData 来处理文件数据,并通过 multipart/form-data 请求头发送。

8. 滚动和视图更新

const scrollToBottom = async () => {
  await nextTick()
  if (messagesContainer.value) {
    const scrollOptions = {
      top: messagesContainer.value.scrollHeight,
      behavior: 'smooth'
    }
    messagesContainer.value.scrollTo(scrollOptions)
  }
}

scrollToBottom 方法确保消息列表在更新后自动滚动到最新消息。这是通过访问 messagesContainer 的 DOM 节点,并设置其 scrollTop 属性实现的。

总结

本系统利用 Vue 3Element Plus 实现了一个高效、互动性强的 AI 聊天助手界面。通过集成历史消息管理、文件上传、向量存储等功能,提供了一个全面的对话平台,满足了用户在日常工作中的各种需求。