基于 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.ts3. 核心功能实现
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-ui 的 message 组件提示 |
5. 优化建议
- 消息持久化: 使用
localStorage或数据库保存对话历史。 - 知识库选择: 在界面增加下拉框,允许用户选择提问的知识库。
- 复制功能: 为 AI 回答添加“复制”按钮。
- Markdown 渲染: 如果 AI 返回 Markdown,使用
marked或highlight.js渲染。 - 语音输入: 集成 Web Speech API 实现语音提问。
- 主题切换: 支持暗色模式。
- 性能优化: 对长消息列表使用虚拟滚动 (
vue-virtual-scroller)。
6. 总结
本技术报告提供了一套完整的、基于 Vue3 + Naive UI 的智能体对话前端实现方案。通过 Composable 模式封装 WebSocket 逻辑,结合 Naive UI 的优雅组件,实现了:
- 🔹 实时流式响应,提升用户体验
- 🔹 清晰的组件拆分,便于维护和扩展
- 🔹 现代化的 UI 设计,简洁美观
- 🔹 完善的错误处理和状态反馈
该方案可直接集成到您的项目中,为用户提供媲美主流 AI 产品的交互体验。
下一步行动建议:
- 创建
composables/useWebSocket.ts。 - 创建
components/MessageItem.vue和ChatInterface.vue。 - 在主页面引入
ChatInterface。 - 配置
.env文件中的VITE_WS_URL。 - 测试 WebSocket 连接、认证、提问和流式响应。
- 根据需求进行 UI 优化和功能扩展。