Appearance
实现第一个GPT聊天机器人(前端)
章节介绍
本章承接后端开发内容,聚焦LLMOps项目前端GPT聊天机器人的完整实现流程。从页面布局搭建、聊天组件封装入手,逐步完成流式消息展示、接口联调、历史记录存储、加载状态优化等核心功能,最终实现与后端联动、体验流畅的企业级聊天界面,同时强化前端工程化与用户体验设计。
一、页面布局与基础组件搭建
1.1 聊天页面路由配置
首先在前端路由中新增聊天机器人页面,指定独立布局与访问路径,实现页面隔离。
typescript
// src/router/index.ts
import ChatLayout from '@/views/layouts/ChatLayout.vue'
import ChatBotView from '@/views/pages/ChatBotView.vue'
const routes = [
// 其他路由...
{
path: '/chat',
name: 'chatBot',
component: ChatBotView,
meta: {
title: 'GPT聊天机器人', // 页面标题
requiresAuth: true // 需要登录校验
}
}
]1.2 核心布局设计(ChatLayout.vue)
采用上中下三段式布局,适配聊天场景的核心交互逻辑,结合ArcoDesign与TailwindCSS实现响应式设计:
vue
<template>
<div class="flex flex-col h-screen bg-gray-50">
<!-- 顶部导航栏 -->
<a-layout-header class="bg-white shadow-sm px-4">
<div class="flex items-center justify-between h-16">
<h1 class="text-xl font-semibold text-gray-800">GPT聊天助手</h1>
<a-avatar shape="circle" size="small" />
</div>
</a-layout-header>
<!-- 中间聊天区域(自动滚动) -->
<a-layout-content class="flex-1 overflow-auto p-4">
<slot name="chat-content" />
</a-layout-content>
<!-- 底部输入区域 -->
<a-layout-footer class="bg-white p-4 border-t">
<slot name="chat-input" />
</a-layout-footer>
</div>
</template>
<script setup lang="ts">
import { Layout } from '@arco-design/web-vue'
const { Header, Content, Footer } = Layout
</script>1.3 聊天页面整合(ChatBotView.vue)
引入布局组件,挂载聊天记录与输入框核心模块,搭建页面骨架:
vue
<template>
<ChatLayout>
<template #chat-content>
<!-- 聊天记录列表 -->
<ChatMessageList :messages="messages" ref="messageListRef" />
</template>
<template #chat-input>
<!-- 消息输入框 -->
<ChatInput @send="handleSendMessage" :loading="isLoading" />
</template>
</ChatLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ChatLayout from '@/views/layouts/ChatLayout.vue'
import ChatMessageList from '@/components/chat/ChatMessageList.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import { useChatStore } from '@/stores/chat'
import { sendChatMessage } from '@/services/chatService'
// 状态管理
const chatStore = useChatStore()
const messages = ref(chatStore.messages)
const isLoading = ref(false)
const messageListRef = ref()
// 发送消息处理
const handleSendMessage = async (content: string) => {
// 后续实现
}
</script>二、核心组件封装:聊天记录与输入框
2.1 聊天消息项组件(ChatMessageItem.vue)
封装单条消息的展示样式,区分用户消息与AI消息,支持头像、内容排版与加载状态:
vue
<template>
<div
class="flex mb-4"
:class="{ 'flex-row-reverse': message.role === 'user' }"
>
<!-- 头像 -->
<a-avatar
class="w-10 h-10 mr-3"
:class="{ 'ml-3 mr-0': message.role === 'user' }"
:src="message.avatar"
:alt="message.role"
/>
<!-- 消息内容 -->
<div class="flex flex-col max-w-[70%]">
<span class="text-xs text-gray-500 mb-1">
{{ message.role === 'user' ? '我' : 'AI助手' }}
</span>
<div
class="rounded-lg px-4 py-2"
:class="{
'bg-blue-500 text-white': message.role === 'user',
'bg-white shadow-sm text-gray-800': message.role === 'assistant'
}"
>
<!-- 加载中状态 -->
<template v-if="loading">
<a-skeleton :rows="3" :width="['80%', '60%', '40%']" />
</template>
<!-- 普通消息 -->
<template v-else>
<span v-html="message.content" />
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
import { Avatar, Skeleton } from '@arco-design/web-vue'
// 消息类型定义
interface Message {
id: string
role: 'user' | 'assistant'
content: string
avatar?: string
createTime: Date
}
// 接收属性
defineProps<{
message: Message
loading?: boolean
}>()
</script>2.2 聊天记录列表组件(ChatMessageList.vue)
实现消息列表渲染、自动滚动到底部、历史记录加载,确保用户体验流畅:
vue
<template>
<div class="w-full">
<!-- 空状态 -->
<div v-if="messages.length === 0" class="text-center py-12">
<a-empty description="开始与AI助手聊天吧~" />
</div>
<!-- 消息列表 -->
<template v-else>
<ChatMessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
:loading="loadingId === msg.id"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { defineProps, ref, watch, onMounted } from 'vue'
import { Empty } from '@arco-design/web-vue'
import ChatMessageItem from './ChatMessageItem.vue'
import type { Message } from './ChatMessageItem.vue'
// 接收属性
const props = defineProps<{
messages: Message[]
loadingId?: string
}>()
// 自动滚动到底部
const scrollToBottom = () => {
const container = document.querySelector('.a-layout-content')
if (container) {
container.scrollTop = container.scrollHeight
}
}
// 监听消息变化
watch(
() => props.messages,
() => {
nextTick(() => scrollToBottom())
},
{ deep: true }
)
onMounted(() => {
scrollToBottom()
})
</script>2.3 消息输入框组件(ChatInput.vue)
封装输入框、发送按钮、加载状态,支持回车发送与字数限制,提升交互体验:
vue
<template>
<div class="flex items-end gap-3">
<!-- 输入框 -->
<a-input
v-model="inputValue"
type="textarea"
:rows="3"
placeholder="请输入您的问题..."
class="flex-1"
:maxlength="500"
@keydown="handleKeyDown"
:disabled="loading"
/>
<!-- 发送按钮 -->
<a-button
type="primary"
@click="handleSend"
:loading="loading"
class="mb-1"
>
发送
</a-button>
</div>
</template>
<script setup lang="ts">
import { ref, defineEmits, defineProps } from 'vue'
import { Input, Button } from '@arco-design/web-vue'
const inputValue = ref('')
const emits = defineEmits<{
(e: 'send', content: string): void
}>()
const props = defineProps<{
loading: boolean
}>()
// 发送消息
const handleSend = () => {
if (!inputValue.value.trim() || props.loading) return
emits('send', inputValue.value.trim())
inputValue.value = ''
}
// 回车发送(Shift+回车换行)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>三、状态管理与历史记录持久化
3.1 聊天状态仓库(stores/chat.ts)
使用Pinia管理聊天状态,实现消息存储、历史记录持久化(localStorage),确保页面刷新后消息不丢失:
typescript
// src/stores/chat.ts
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import type { Message } from '@/components/chat/ChatMessageItem.vue'
// 本地存储key
const STORAGE_KEY = 'llmops_chat_history'
export const useChatStore = defineStore('chat', () => {
// 聊天消息列表
const messages = ref<Message[]>([])
// 从本地存储加载历史记录
const loadHistory = () => {
const history = localStorage.getItem(STORAGE_KEY)
if (history) {
messages.value = JSON.parse(history, (key, value) => {
if (key === 'createTime') return new Date(value)
return value
})
}
}
// 保存历史记录到本地存储
const saveHistory = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages.value))
}
// 添加消息
const addMessage = (message: Message) => {
messages.value.push(message)
}
// 清空消息
const clearMessages = () => {
messages.value = []
localStorage.removeItem(STORAGE_KEY)
}
// 监听消息变化,自动保存
watch(messages, () => saveHistory(), { deep: true })
return {
messages,
loadHistory,
addMessage,
clearMessages
}
})3.2 状态初始化
在聊天页面挂载时加载历史记录,确保用户再次进入时能看到之前的对话:
typescript
// ChatBotView.vue 中补充
onMounted(() => {
chatStore.loadHistory()
messages.value = chatStore.messages
})四、接口联调:实现普通消息与流式响应
4.1 聊天服务封装(services/chatService.ts)
基于之前封装的Fetch请求,对接后端聊天接口,分别实现普通响应与流式响应:
typescript
// src/services/chatService.ts
import { post } from '@/utils/request'
// 普通聊天请求(非流式)
export const sendChatMessage = (content: string) => {
return post<{ content: string }>('/app/completion', {
body: { query: content }
})
}
// 流式聊天请求
export const sendChatMessageStream = (
content: string,
onMessage: (content: string) => void,
onError: (err: any) => void,
onComplete: () => void
) => {
const abortController = new AbortController()
fetch(`${import.meta.env.VITE_API_PREFIX}/app/completion/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ query: content }),
signal: abortController.signal
})
.then((res) => {
if (!res.ok) throw new Error('请求失败')
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let fullContent = ''
const read = async () => {
if (!reader) return
const { done, value } = await reader.read()
if (done) {
onComplete()
return
}
const chunk = decoder.decode(value, { stream: true })
fullContent += chunk
onMessage(fullContent)
read()
}
read()
})
.catch((err) => {
if (err.name !== 'AbortError') {
onError(err)
}
})
return abortController
}4.2 实现消息发送与流式展示
在聊天页面中完善handleSendMessage方法,处理用户消息发送、AI流式响应、加载状态、异常处理:
typescript
// ChatBotView.vue 中完善 handleSendMessage
const handleSendMessage = async (content: string) => {
// 1. 生成用户消息并添加到列表
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content,
avatar: 'https://picsum.photos/200/200?random=1',
createTime: new Date()
}
chatStore.addMessage(userMessage)
// 2. 生成AI消息占位符(用于加载状态)
const aiMessageId = (Date.now() + 1).toString()
const aiMessage: Message = {
id: aiMessageId,
role: 'assistant',
content: '',
avatar: 'https://picsum.photos/200/200?random=2',
createTime: new Date()
}
chatStore.addMessage(aiMessage)
// 3. 发起流式请求
let abortController: AbortController
try {
abortController = sendChatMessageStream(
content,
(chunkContent) => {
// 更新AI消息内容(实时流式展示)
aiMessage.content = chunkContent
},
(err) => {
// 异常处理
aiMessage.content = `请求失败:${err.message}`
},
() => {
// 请求完成,更新状态
}
)
} catch (err) {
aiMessage.content = '请求异常,请稍后重试'
}
}4.3 支持Markdown渲染
AI响应常包含Markdown格式,集成vue-markdown-render实现富文本展示,提升阅读体验:
- 安装依赖:
yarn add vue-markdown-render --dev - 修改ChatMessageItem.vue:
vue
<!-- 替换原消息内容的span -->
<vue-markdown-render
v-if="message.role === 'assistant'"
:source="message.content"
class="markdown-content"
/>
<span v-else v-html="message.content" />
<script setup lang="ts">
// 新增导入
import VueMarkdownRender from 'vue-markdown-render'
import 'vue-markdown-render/dist/style.css'
</script>
<style scoped>
/* 自定义Markdown样式 */
.markdown-content :deep(p) {
margin: 0 0 8px 0;
}
.markdown-content :deep(code) {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 4px;
}
</style>五、体验优化与异常处理
5.1 加载状态优化
在ChatBotView.vue中添加loadingId,关联AI消息的加载状态,实现骨架屏展示:
typescript
// ChatBotView.vue 中新增状态
const loadingId = ref('')
// 在handleSendMessage中修改
const handleSendMessage = async (content: string) => {
// ... 省略其他代码
// 2. 生成AI消息占位符时,设置loadingId
loadingId.value = aiMessageId
chatStore.addMessage(aiMessage)
// 3. 发起请求后,在完成/异常时清除loadingId
try {
abortController = sendChatMessageStream(
content,
(chunkContent) => {
aiMessage.content = chunkContent
},
(err) => {
aiMessage.content = `请求失败:${err.message}`
loadingId.value = '' // 清除加载
},
() => {
loadingId.value = '' // 清除加载
}
)
} catch (err) {
aiMessage.content = '请求异常,请稍后重试'
loadingId.value = ''
}
}5.2 异常处理与重试
添加网络异常提示与重试按钮,提升用户体验:
- 在ChatMessageItem.vue中新增重试事件:
vue
<!-- 在消息内容后添加重试按钮 -->
<template v-if="message.content.includes('请求失败')">
<a-button size="mini" class="mt-2" @click="$emit('retry')"> 重试 </a-button>
</template>
// 新增emit const emits = defineEmits<{ (e: 'retry'): void }>()- 在ChatMessageList.vue中传递重试事件:
vue
<ChatMessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
:loading="loadingId === msg.id"
@retry="() => emits('retry', msg)"
/>
// 新增emit const emits = defineEmits<{ (e: 'retry', message: Message): void
}>()- 在ChatBotView.vue中实现重试逻辑:
vue
<!-- 监听重试事件 -->
<ChatMessageList
:messages="messages"
:loading-id="loadingId"
ref="messageListRef"
@retry="handleRetry"
/>
// 实现重试方法 const handleRetry = (message: Message) => { //
找到对应的用户消息 const userMessage = messages.value.find( msg => msg.id ===
(Number(message.id) - 1).toString() ) if (userMessage) { // 删除失败的AI消息
messages.value = messages.value.filter(msg => msg.id !== message.id) // 重新发送
handleSendMessage(userMessage.content) } }5.3 清空聊天记录
在顶部导航栏添加清空按钮,实现一键清空功能:
vue
<!-- 在ChatLayout.vue的导航栏中添加 -->
<a-button size="mini" @click="$emit('clear')">
清空记录
</a-button>
// 传递事件到ChatBotView.vue,调用store方法
<ChatLayout @clear="chatStore.clearMessages">
<!-- 内容不变 -->
</ChatLayout>六、本章核心总结
- 组件化开发:封装聊天消息项、消息列表、输入框等核心组件,实现代码复用与职责分离;
- 状态管理:基于Pinia实现聊天状态管理,结合localStorage完成历史记录持久化,确保数据不丢失;
- 接口联调:完成普通请求与流式请求的封装,实现AI消息的实时流式展示,提升交互体验;
- 富文本支持:集成Markdown渲染,适配AI响应的格式要求,优化内容展示效果;
- 体验优化:添加加载状态、异常处理、重试功能、清空记录等特性,打造企业级用户体验;
- 前后端联动:实现前端与后端GPT聊天接口的完整对接,完成第一个前端聊天机器人的落地。