Skip to content

基于 Vue3 + Naive UI + WebSocket 的智能体对话功能技术报告

技术栈: Vue3 (Composition API) + Vite + Naive UI + WebSocket
后端对接: NestJS WebSocket Gateway (流式 RAG 问答)
报告目标: 指导前端团队实现一个现代化、用户体验优良的智能体对话界面,支持流式输出、消息历史、加载状态和错误处理。


1. 概述

本报告旨在设计并实现一个基于 Vue3 和 Naive UI 的智能体对话前端界面。该界面将通过 WebSocket 与后端 RAG 系统通信,实现流式响应(类似 ChatGPT 的打字机效果),为用户提供实时、交互性强的问答体验。

核心功能包括:

  • ✅ WebSocket 连接管理(连接、认证、断开)
  • ✅ 流式文本实时渲染
  • ✅ 消息列表展示(用户提问 + AI 回答)
  • ✅ 消息加载状态与错误提示
  • ✅ 平滑的滚动到底部动画
  • ✅ 简洁美观的 UI 设计(使用 Naive UI)

2. 环境准备与依赖

2.1 安装依赖

bash
npm install naive-ui
npm install @vicons/ionicons5 # 推荐图标库,与 Naive UI 配合良好

2.2 项目结构建议

src/
├── components/
│   └── ChatInterface.vue        # 对话主界面
│   └── MessageItem.vue          # 单条消息组件
├── composables/
│   └── useWebSocket.ts          # WebSocket 逻辑封装
├── assets/
│   └── logo.png
├── App.vue
└── main.ts

3. 核心功能实现

3.1 封装 WebSocket 逻辑 (Composable)

使用 Vue3 的 Composable 模式封装 WebSocket 逻辑,实现高内聚、可复用。

typescript
// composables/useWebSocket.ts
import { ref, Ref } from 'vue'
import { message } from 'naive-ui'

// 定义消息类型
export interface Message {
  id: string
  type: 'user' | 'ai' | 'system'
  content: string
  timestamp: number
  sources?: Array<{ content: string; metadata: any }>
}

export interface ServerMessage {
  type: 'auth_success' | 'auth_error' | 'answer_token' | 'answer_complete' | 'error'
  data: any
}

export function useWebSocket() {
  const socket = ref<WebSocket | null>(null)
  const isConnected = ref(false)
  const isConnecting = ref(false)
  const isSending = ref(false)
  const messages = ref<Message[]>([])
  const currentAnswer = ref('') // 用于存储正在流式生成的 AI 回答

  // 生成唯一 ID
  const generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2)

  // 连接 WebSocket
  const connect = (token: string) => {
    isConnecting.value = true
    const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3000/ws/rag'
    socket.value = new WebSocket(wsUrl)

    socket.value.onopen = () => {
      console.log('WebSocket connected')
      isConnected.value = true
      isConnecting.value = false
      // 发送认证
      sendMessage({ type: 'auth', data: { token } })
    }

    socket.value.onmessage = (event) => {
      try {
        const data: ServerMessage = JSON.parse(event.data)
        handleMessage(data)
      } catch (error) {
        console.error('Parse message error:', error)
        message.error('消息解析失败')
      }
    }

    socket.value.onclose = (event) => {
      console.log('WebSocket closed', event)
      isConnected.value = false
      isConnecting.value = false
      message.warning('连接已断开')
    }

    socket.value.onerror = (error) => {
      console.error('WebSocket error:', error)
      message.error('连接出错')
    }
  }

  // 发送消息到服务器
  const sendMessage = (payload: any) => {
    if (socket.value && socket.value.readyState === WebSocket.OPEN) {
      socket.value.send(JSON.stringify(payload))
    } else {
      message.error('连接未建立')
    }
  }

  // 处理服务器消息
  const handleMessage = (msg: ServerMessage) => {
    switch (msg.type) {
      case 'auth_success':
        message.success('认证成功')
        break
      case 'auth_error':
        message.error('认证失败: ' + msg.data.message)
        break
      case 'answer_token':
        currentAnswer.value += msg.data.token
        // 实时更新最后一条 AI 消息
        const lastMsg = messages.value[messages.value.length - 1]
        if (lastMsg && lastMsg.type === 'ai') {
          lastMsg.content = currentAnswer.value
        }
        break
      case 'answer_complete':
        // 完成回答,清除 currentAnswer
        currentAnswer.value = ''
        messages.value.push({
          id: generateId(),
          type: 'ai',
          content: msg.data.answer,
          timestamp: Date.now(),
          sources: msg.data.sources
        })
        break
      case 'error':
        message.error('AI 错误: ' + msg.data.message)
        // 可以在消息列表中添加一条错误消息
        messages.value.push({
          id: generateId(),
          type: 'system',
          content: `错误: ${msg.data.message}`,
          timestamp: Date.now()
        })
        break
      default:
        console.warn('Unknown message type:', msg.type)
    }
  }

  // 发送用户提问
  const sendQuestion = (query: string, knowledgeBaseId?: string) => {
    if (!query.trim()) return
    isSending.value = true

    // 先添加用户消息
    messages.value.push({
      id: generateId(),
      type: 'user',
      content: query,
      timestamp: Date.now()
    })

    // 发送请求
    sendMessage({
      type: 'ask',
      data: { query, knowledgeBaseId }
    })

    isSending.value = false
  }

  // 断开连接
  const disconnect = () => {
    if (socket.value) {
      socket.value.close()
    }
  }

  return {
    socket,
    isConnected,
    isConnecting,
    isSending,
    messages,
    currentAnswer,
    connect,
    sendQuestion,
    disconnect
  }
}

3.2 创建对话主界面 (ChatInterface.vue)

vue
<!-- components/ChatInterface.vue -->
<template>
  <n-card 
    title="智能知识助手" 
    class="chat-container"
    :bordered="false"
    size="large"
  >
    <!-- 消息列表 -->
    <div ref="messagesContainer" class="messages-container">
      <n-empty 
        v-if="messages.length === 0" 
        description="欢迎使用智能助手,请开始提问吧!" 
        size="large"
      />
      
      <template v-for="msg in messages" :key="msg.id">
        <MessageItem :message="msg" />
      </template>

      <!-- 流式输出中的 AI 消息 -->
      <MessageItem 
        v-if="currentAnswer" 
        :message="{
          id: 'streaming',
          type: 'ai',
          content: currentAnswer,
          timestamp: Date.now()
        }" 
        is-streaming 
      />
    </div>

    <!-- 输入区域 -->
    <template #footer>
      <n-input
        v-model:value="inputValue"
        type="textarea"
        placeholder="请输入您的问题..."
        :autosize="{ minRows: 1, maxRows: 4 }"
        :disabled="isSending || !isConnected"
        @keydown.enter="handleEnter"
        @update:value="handleInput"
      >
        <template #suffix>
          <n-button 
            :disabled="!inputValue.trim() || isSending || !isConnected" 
            :loading="isSending"
            @click="handleSubmit"
            size="small"
            circle
          >
            <n-icon><SendOutlined /></n-icon>
          </n-button>
        </template>
      </n-input>
      
      <n-text depth="3" style="font-size: 12px; margin-top: 4px;">
        {{ isConnected ? '连接正常' : '连接中...' }}
      </n-text>
    </template>
  </n-card>
</template>

<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { 
  NCard, 
  NInput, 
  NButton, 
  NIcon, 
  NEmpty,
  NText,
  useMessage 
} from 'naive-ui'
import { SendOutlined } from '@vicons/ionicons5'
import MessageItem from './MessageItem.vue'
import { useWebSocket } from '../composables/useWebSocket'

defineOptions({
  name: 'ChatInterface'
})

const message = useMessage()

// 使用 WebSocket Composable
const {
  isConnected,
  isConnecting,
  isSending,
  messages,
  currentAnswer,
  connect,
  sendQuestion,
  disconnect
} = useWebSocket()

const inputValue = ref('')
const messagesContainer = ref<HTMLElement | null>(null)

// 滚动到底部
const scrollToBottom = async () => {
  await nextTick()
  if (messagesContainer.value) {
    messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
  }
}

// 监听消息变化,自动滚动
watch(messages, scrollToBottom, { deep: true })
watch(currentAnswer, scrollToBottom)

// Enter 发送,Shift+Enter 换行
const handleEnter = (e: KeyboardEvent) => {
  if (!e.shiftKey) {
    e.preventDefault()
    handleSubmit()
  }
}

const handleInput = (value: string) => {
  inputValue.value = value
}

const handleSubmit = () => {
  if (inputValue.value.trim()) {
    sendQuestion(inputValue.value)
    inputValue.value = ''
  }
}

// 组件挂载时连接 WebSocket
onMounted(() => {
  // 假设 token 从 localStorage 或 Pinia Store 获取
  const token = localStorage.getItem('auth_token')
  if (token) {
    connect(token)
  } else {
    message.error('请先登录')
  }
})

// 组件卸载时断开连接
onUnmounted(() => {
  disconnect()
})
</script>

<style scoped>
.chat-container {
  max-width: 800px;
  margin: 20px auto;
  height: 80vh;
  display: flex;
  flex-direction: column;
}

.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  border: 1px solid #f0f0f0;
  border-radius: 8px;
  background-color: #fafafa;
  margin-bottom: 16px;
}

:deep(.n-input) {
  margin-bottom: 8px;
}
</style>

3.3 创建消息项组件 (MessageItem.vue)

vue
<!-- components/MessageItem.vue -->
<template>
  <div 
    :class="['message-item', message.type]" 
    :style="{ opacity: isStreaming ? 0.8 : 1 }"
  >
    <!-- 头像/图标 -->
    <n-avatar 
      :size="40" 
      :style="{ backgroundColor: avatarBg }"
      :src="message.type === 'user' ? userAvatar : aiAvatar"
    >
      {{ message.type === 'user' ? 'U' : 'AI' }}
    </n-avatar>
    
    <!-- 消息内容 -->
    <div class="message-content">
      <n-text :depth="message.type === 'system' ? 3 : undefined">
        {{ message.content }}
      </n-text>
      
      <!-- 来源信息 (可选) -->
      <n-collapse v-if="message.sources && message.sources.length > 0" arrow-placement="right">
        <n-collapse-item title="引用来源">
          <n-list bordered>
            <n-list-item v-for="(source, index) in message.sources" :key="index">
              <n-ellipsis :line-clamp="3">{{ source.content }}</n-ellipsis>
              <template #suffix>
                <n-tag size="small" type="info">
                  {{ source.metadata.source || '未知' }}
                </n-tag>
              </template>
            </n-list-item>
          </n-list>
        </n-collapse-item>
      </n-collapse>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { 
  NAvatar, 
  NText, 
  NCollapse, 
  NCollapseItem, 
  NList, 
  NListItem, 
  NTag,
  NEllipsis
} from 'naive-ui'

defineProps<{
  message: {
    type: 'user' | 'ai' | 'system'
    content: string
    sources?: Array<{ content: string; metadata: any }>
  }
  isStreaming?: boolean
}>()

const userAvatar = new URL('../assets/user-avatar.png', import.meta.url).href
const aiAvatar = new URL('../assets/ai-avatar.png', import.meta.url).href

const avatarBg = computed(() => {
  switch (props.message.type) {
    case 'user': return '#409eff'
    case 'ai': return '#626aef'
    case 'system': return '#909399'
  }
})
</script>

<style scoped>
.message-item {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
  animation: fadeIn 0.3s ease-in;
}

.message-item.user {
  flex-direction: row-reverse;
  gap: 8px;
}

.message-item.user .message-content {
  text-align: right;
}

.message-content {
  flex: 1;
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 18px;
  line-height: 1.5;
}

.message-item.user .message-content {
  background-color: #409eff;
  color: white;
}

.message-item.ai .message-content,
.message-item.system .message-content {
  background-color: white;
  border: 1px solid #e8eaec;
  box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
</style>

4. 关键特性说明

特性实现方式
流式输出useWebSocket 监听 answer_token,动态更新 currentAnswer 和最后一条消息
自动滚动watch 消息列表和 currentAnswer,调用 scrollToBottom
Enter 发送@keydown.enter + e.preventDefault() 阻止默认换行
Shift+Enter 换行e.shiftKey 判断,允许换行
加载状态isSending 控制按钮 loading 状态
连接状态isConnected / isConnecting 显示连接状态
错误处理WebSocket onerror 和服务器 error 消息,使用 naive-uimessage 组件提示

5. 优化建议

  1. 消息持久化: 使用 localStorage 或数据库保存对话历史。
  2. 知识库选择: 在界面增加下拉框,允许用户选择提问的知识库。
  3. 复制功能: 为 AI 回答添加“复制”按钮。
  4. Markdown 渲染: 如果 AI 返回 Markdown,使用 markedhighlight.js 渲染。
  5. 语音输入: 集成 Web Speech API 实现语音提问。
  6. 主题切换: 支持暗色模式。
  7. 性能优化: 对长消息列表使用虚拟滚动 (vue-virtual-scroller)。

6. 总结

本技术报告提供了一套完整的、基于 Vue3 + Naive UI 的智能体对话前端实现方案。通过 Composable 模式封装 WebSocket 逻辑,结合 Naive UI 的优雅组件,实现了:

  • 🔹 实时流式响应,提升用户体验
  • 🔹 清晰的组件拆分,便于维护和扩展
  • 🔹 现代化的 UI 设计,简洁美观
  • 🔹 完善的错误处理和状态反馈

该方案可直接集成到您的项目中,为用户提供媲美主流 AI 产品的交互体验。

下一步行动建议:

  1. 创建 composables/useWebSocket.ts
  2. 创建 components/MessageItem.vueChatInterface.vue
  3. 在主页面引入 ChatInterface
  4. 配置 .env 文件中的 VITE_WS_URL
  5. 测试 WebSocket 连接、认证、提问和流式响应。
  6. 根据需求进行 UI 优化和功能扩展。