Skip to content

实现第一个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实现富文本展示,提升阅读体验:

  1. 安装依赖:yarn add vue-markdown-render --dev
  2. 修改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 异常处理与重试 ​

添加网络异常提示与重试按钮,提升用户体验:

  1. 在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 }>()
  1. 在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
}>()
  1. 在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>

六、本章核心总结 ​

  1. 组件化开发:封装聊天消息项、消息列表、输入框等核心组件,实现代码复用与职责分离;
  2. 状态管理:基于Pinia实现聊天状态管理,结合localStorage完成历史记录持久化,确保数据不丢失;
  3. 接口联调:完成普通请求与流式请求的封装,实现AI消息的实时流式展示,提升交互体验;
  4. 富文本支持:集成Markdown渲染,适配AI响应的格式要求,优化内容展示效果;
  5. 体验优化:添加加载状态、异常处理、重试功能、清空记录等特性,打造企业级用户体验;
  6. 前后端联动:实现前端与后端GPT聊天接口的完整对接,完成第一个前端聊天机器人的落地。

Talk is cheap, show me the code!