最近在做一个智能客服系统的前端项目,用上了 Vue 3 和 TypeScript,还集成了 AI 服务。整个过程下来,感觉和以前用 Vue 2 做复杂交互应用很不一样,尤其是处理多轮对话状态、实时通信这些场景。今天就把我的搭建思路和踩过的一些坑整理一下,希望能给想尝试类似项目的朋友一些参考。

1. 项目背景与核心挑战

智能客服前端听起来就是个聊天界面,但真做起来,会发现不少技术难点。最头疼的主要是两点:

多轮对话的状态维护:用户和 AI 的对话不是一问一答就结束的。比如用户先问“你们的退货政策是什么?”,客服 AI 回答后,用户可能接着问“那运费谁承担?”。AI 需要理解这个“那”指的是上一轮的“退货”,这就需要前端把对话的上下文(Context)完整、有序地维护好,并在每次请求时准确传递给后端。

AI 响应延迟与异步处理:调用大语言模型的 API,响应时间不稳定,有时快有时慢。前端需要处理这种不确定性,比如在等待时显示“思考中...”的加载状态,还要处理可能发生的网络超时或服务错误,给出友好的降级提示(如“客服正在忙,请稍后再试”),而不是直接白屏或报错。

智能客服系统界面示意

2. 为什么选择 Vue 3?

之前也考虑过 React 或其他框架,但最终选择了 Vue 3,主要是看中了它在复杂交互场景下的几个优势:

  1. 组合式 API (Composition API):这是最大的亮点。对于智能客服这种逻辑复杂的应用,传统的 Options API 会把逻辑分散在 datamethodscomputed 等各个选项中,维护起来很费劲。而 Composition API 允许我们把“对话管理”、“网络请求”、“AI 逻辑”这些功能,封装成一个个独立的、可复用的自定义 Hook(在 Vue 里叫 composable),代码组织清晰多了。
  2. 更好的 TypeScript 支持:Vue 3 从头到尾用 TypeScript 重写,类型推断非常完善。这对于需要严格定义“消息对象”、“对话状态”、“API 响应格式”的项目来说,能极大减少低级错误,提升开发体验。
  3. 响应式系统升级:Vue 3 的响应式系统更高效、更灵活。在处理实时更新的对话列表、在线状态等数据时,性能表现更好。

当然,Vue 3 的学习曲线对于 Vue 2 用户来说确实存在,但一旦熟悉了 Composition API 的思维方式,开发效率会高很多。

3. 核心实现步骤拆解

下面我分模块讲讲具体是怎么做的。

3.1 使用 Pinia 管理全局对话状态

状态管理我用的是 Pinia,它比 Vuex 更简单,也完美支持 Composition API。我定义了一个 useConversationStore 来管理所有对话相关的状态。

// stores/conversation.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

// 定义类型
export interface Message {
  id: string;
  content: string;
  sender: 'user' | 'ai';
  timestamp: number;
  status: 'sending' | 'success' | 'error';
}

export interface Conversation {
  id: string;
  title: string;
  messages: Message[];
  createdAt: number;
}

export const useConversationStore = defineStore('conversation', () => {
  // 状态
  const currentConversationId = ref<string>('');
  const conversations = ref<Conversation[]>([]);
  const isLoading = ref<boolean>(false);

  // Getter
  const currentConversation = computed(() => {
    return conversations.value.find(c => c.id === currentConversationId.value) || null;
  });

  const currentMessages = computed(() => {
    return currentConversation.value?.messages || [];
  });

  // Action
  const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
    const conv = currentConversation.value;
    if (!conv) return;
    const newMsg: Message = {
      ...message,
      id: `msg_${Date.now()}`,
      timestamp: Date.now(),
    };
    conv.messages.push(newMsg);
  };

  const updateMessageStatus = (messageId: string, status: Message['status']) => {
    const conv = currentConversation.value;
    if (!conv) return;
    const msg = conv.messages.find(m => m.id === messageId);
    if (msg) {
      msg.status = status;
    }
  };

  // 初始化或切换对话
  const switchConversation = (convId?: string) => {
    if (!convId) {
      // 创建新对话
      const newConv: Conversation = {
        id: `conv_${Date.now()}`,
        title: '新对话',
        messages: [],
        createdAt: Date.now(),
      };
      conversations.value.unshift(newConv); // 新对话放在最前面
      currentConversationId.value = newConv.id;
    } else {
      currentConversationId.value = convId;
    }
    isLoading.value = false;
  };

  return {
    currentConversationId,
    conversations,
    isLoading,
    currentConversation,
    currentMessages,
    addMessage,
    updateMessageStatus,
    switchConversation,
  };
});

这样,在任何组件里,我都可以方便地获取和修改对话状态,逻辑非常清晰。

3.2 WebSocket 连接封装与重试机制

为了实时接收 AI 的流式响应(比如一个字一个字地返回),我选择了 WebSocket。封装了一个带自动重连和心跳检测的 Hook。

// composables/useWebSocket.ts
import { ref, onUnmounted } from 'vue';

interface UseWebSocketOptions {
  url: string;
  onMessage: (event: MessageEvent) => void;
  onOpen?: () => void;
  onClose?: () => void;
  onError?: (event: Event) => void;
  reconnectAttempts?: number;
  reconnectInterval?: number;
}

export function useWebSocket(options: UseWebSocketOptions) {
  const socket = ref<WebSocket | null>(null);
  const isConnected = ref(false);
  const reconnectCount = ref(0);
  const maxReconnect = options.reconnectAttempts || 5;
  const reconnectDelay = options.reconnectInterval || 3000;
  let heartbeatTimer: number | null = null;

  const connect = () => {
    try {
      const ws = new WebSocket(options.url);
      socket.value = ws;

      ws.onopen = () => {
        console.log('WebSocket connected');
        isConnected.value = true;
        reconnectCount.value = 0;
        options.onOpen?.();
        startHeartbeat();
      };

      ws.onmessage = options.onMessage;

      ws.onclose = (event) => {
        console.log('WebSocket disconnected', event.code, event.reason);
        isConnected.value = false;
        stopHeartbeat();
        options.onClose?.();
        // 非正常关闭,尝试重连
        if (event.code !== 1000 && reconnectCount.value < maxReconnect) {
          setTimeout(() => {
            reconnectCount.value += 1;
            console.log(`Attempting to reconnect... (${reconnectCount.value}/${maxReconnect})`);
            connect();
          }, reconnectDelay);
        }
      };

      ws.onerror = (error) => {
        console.error('WebSocket error:', error);
        isConnected.value = false;
        options.onError?.(error);
      };

    } catch (error) {
      console.error('Failed to create WebSocket:', error);
    }
  };

  const send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
    if (socket.value && isConnected.value) {
      socket.value.send(data);
    } else {
      console.error('WebSocket is not connected');
      // 可以在这里将消息加入队列,等连接恢复后发送
    }
  };

  const disconnect = () => {
    if (socket.value) {
      socket.value.close(1000, 'Normal closure');
      socket.value = null;
    }
    stopHeartbeat();
  };

  const startHeartbeat = () => {
    heartbeatTimer = window.setInterval(() => {
      if (socket.value && isConnected.value) {
        socket.value.send(JSON.stringify({ type: 'ping' }));
      }
    }, 30000); // 每30秒发送一次心跳
  };

  const stopHeartbeat = () => {
    if (heartbeatTimer) {
      clearInterval(heartbeatTimer);
      heartbeatTimer = null;
    }
  };

  onUnmounted(() => {
    disconnect();
  });

  return {
    socket,
    isConnected,
    connect,
    send,
    disconnect,
  };
}
3.3 AI 服务调用与防抖降级处理

调用 AI 接口是核心。我封装了一个 useAIChat 的 composable,集成了防抖、错误处理和降级逻辑。

// composables/useAIChat.ts
import { ref } from 'vue';
import { useConversationStore } from '@/stores/conversation';
import { useWebSocket } from './useWebSocket';

interface AIChatOptions {
  apiEndpoint: string;
  wsEndpoint: string;
  maxRetries?: number;
}

export function useAIChat(options: AIChatOptions) {
  const conversationStore = useConversationStore();
  const isThinking = ref(false);
  const error = ref<string | null>(null);
  let abortController: AbortController | null = null;

  // 防抖发送消息
  const sendMessageToAI = async (userInput: string) => {
    // 1. 清除旧错误,设置加载状态
    error.value = null;
    isThinking.value = true;

    // 2. 在状态管理中添加用户消息
    conversationStore.addMessage({
      content: userInput,
      sender: 'user',
      status: 'success',
    });

    // 3. 为本次请求创建 AI 消息占位符(用于流式更新)
    const aiMessageId = `ai_msg_${Date.now()}`;
    conversationStore.addMessage({
      id: aiMessageId,
      content: '', // 初始为空,通过 WebSocket 流式填充
      sender: 'ai',
      status: 'sending',
    });

    // 4. 准备对话上下文
    const contextMessages = conversationStore.currentMessages
      .slice(-10) // 只发送最近10条作为上下文,防止 token 超限
      .map(msg => ({ role: msg.sender, content: msg.content }));

    const requestPayload = {
      messages: contextMessages,
      stream: true, // 启用流式响应
    };

    try {
      // 方案A: 使用 WebSocket 进行流式对话(主方案)
      const { send, isConnected } = useWebSocket({
        url: options.wsEndpoint,
        onMessage: (event) => {
          const data = JSON.parse(event.data);
          if (data.type === 'chunk') {
            // 流式更新 AI 消息内容
            updateAIMessageContent(aiMessageId, data.content);
          } else if (data.type === 'done') {
            conversationStore.updateMessageStatus(aiMessageId, 'success');
            isThinking.value = false;
          }
        },
      });

      if (isConnected.value) {
        send(JSON.stringify(requestPayload));
      } else {
        // 方案B: WebSocket 未就绪,降级为普通 HTTP 请求
        await fetchAIChatFallback(requestPayload, aiMessageId);
      }

    } catch (err) {
      handleAIChatError(err, aiMessageId);
    }
  };

  // 降级方案:普通 HTTP POST 请求
  const fetchAIChatFallback = async (payload: any, aiMessageId: string) => {
    abortController = new AbortController();
    try {
      const response = await fetch(options.apiEndpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...payload, stream: false }), // 降级为非流式
        signal: abortController.signal,
      });

      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

      const result = await response.json();
      updateAIMessageContent(aiMessageId, result.reply);
      conversationStore.updateMessageStatus(aiMessageId, 'success');

    } catch (err) {
      handleAIChatError(err, aiMessageId);
    } finally {
      isThinking.value = false;
      abortController = null;
    }
  };

  // 更新 AI 消息内容(用于流式拼接)
  const updateAIMessageContent = (messageId: string, chunk: string) => {
    const conv = conversationStore.currentConversation;
    if (!conv) return;
    const msg = conv.messages.find(m => m.id === messageId);
    if (msg && msg.sender === 'ai') {
      msg.content += chunk;
    }
  };

  // 统一错误处理
  const handleAIChatError = (err: any, aiMessageId: string) => {
    console.error('AI chat error:', err);
    conversationStore.updateMessageStatus(aiMessageId, 'error');
    error.value = err.message || 'AI 服务暂时不可用,请稍后重试。';
    isThinking.value = false;
    // 可以在这里触发监控告警
  };

  // 取消请求
  const cancelRequest = () => {
    if (abortController) {
      abortController.abort();
      isThinking.value = false;
      error.value = '请求已取消';
    }
  };

  return {
    sendMessageToAI,
    isThinking,
    error,
    cancelRequest,
  };
}

4. 性能优化:虚拟列表渲染对话记录

当对话历史很长时,渲染所有消息节点会非常消耗性能。我引入了虚拟列表技术,只渲染可视区域内的消息。

我使用了 vue-virtual-scroller 这个库,它非常适合聊天场景。核心思路是计算滚动位置,动态显示和隐藏消息项。

<!-- components/MessageList.vue -->
<template>
  <RecycleScroller
    class="message-scroller"
    :items="formattedMessages"
    :item-size="80"
    key-field="id"
    v-slot="{ item }"
  >
    <MessageBubble :message="item" />
  </RecycleScroller>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import MessageBubble from './MessageBubble.vue';
import { useConversationStore } from '@/stores/conversation';

const conversationStore = useConversationStore();

// 对消息进行预处理,方便虚拟列表计算高度
const formattedMessages = computed(() => {
  return conversationStore.currentMessages.map(msg => ({
    ...msg,
    // 可以根据内容长度估算高度,这里简化处理
    estimatedHeight: Math.max(60, Math.ceil(msg.content.length / 50) * 20),
  }));
});
</script>

<style scoped>
.message-scroller {
  height: 500px;
  overflow-y: auto;
}
</style>

5. 安全考虑:XSS 防护与敏感词过滤

用户输入和 AI 返回的内容都可能存在风险,必须处理。

  1. XSS 防护

    • 渲染时转义:Vue 的模板语法 {{ }} 默认会对内容进行 HTML 转义,这是第一道防线。
    • 谨慎使用 v-html:除非绝对必要且内容可信,否则避免使用 v-html 指令。如果 AI 返回了富文本(比如包含简单排版),必须在后端或前端使用一个安全的 HTML 净化库(如 DOMPurify)进行处理后再渲染。
    import DOMPurify from 'dompurify';
    // ...
    const sanitizedHtml = DOMPurify.sanitize(aiResponseContent);
    
  2. 敏感词过滤

    • 虽然主要过滤责任在后端,但前端也可以做一层基础防护,避免明显违规内容直接显示或发送。可以维护一个轻量级的敏感词库,在用户发送和 AI 回复显示时进行匹配和替换(如替换为 ***)。
    • 更常见的做法是,在前端发送消息前,调用一个内容安全校验接口,由后端返回是否合规的结果。

6. 生产环境部署检查清单

项目开发完,要上线了,下面这些点得再核对一遍:

  1. 构建与发布

    • npm run build 检查是否有错误或警告。
    • 确认 vue.config.jsvite.config.ts 中的 publicPath 是否正确(尤其是使用 CDN 时)。
    • 开启 Gzip/Brotli 压缩(通常在 Web 服务器如 Nginx 配置)。
  2. CDN 与静态资源

    • 将构建出的 dist 目录下的静态文件(JS、CSS、图片)上传至 CDN。
    • 确保 CDN 域名已正确配置,并且在项目中通过环境变量引用。
    • 为静态资源设置合适的缓存策略(如长期缓存),并配置版本哈希以解决更新问题。
  3. API 与 WebSocket 配置

    • 将开发环境的后端地址替换为生产环境地址。务必使用环境变量管理,不要写死在代码里。
    • 检查 WebSocket 连接地址 (wss://) 是否正确,并确认生产环境防火墙/负载均衡器支持 WebSocket 协议。
    • 配置 API 请求的超时时间、重试策略。
  4. 监控与埋点

    • 性能监控:记录页面加载时间、接口响应时间、WebSocket 连接成功率/延迟。
    • 错误监控:全局捕获并上报 JS 错误、未处理的 Promise 拒绝、API 调用失败、WebSocket 异常断开等信息。可以使用 Sentry 或自建监控服务。
    • 业务埋点:记录用户关键行为,如“发送消息次数”、“会话时长”、“AI 响应超时次数”、“用户满意度评价(如果有)”。这些数据对优化产品至关重要。
  5. 安全复查

    • 确保没有将敏感密钥(如 API Key)硬编码在前端代码中。
    • 验证 CSP (Content Security Policy) 头部的配置是否正确,防止 XSS。
    • 确认 HTTPS 已正确部署,特别是 WebSocket (WSS)。
  6. 用户体验兜底

    • 再次测试网络离线、服务端错误等异常情况下的 UI 提示是否友好。
    • 确认加载状态、骨架屏等过渡效果正常工作。

写在最后

从零搭建这个 Vue 3 + AI 的智能客服前端,整个过程就像搭积木,用 Composition API 把功能模块一块块拼起来,再用 Pinia 管理好它们之间的状态流动。最大的体会是,面对 AI 集成这种异步性、不确定性很强的需求,前端的健壮性设计(错误处理、降级、重试)和状态管理清晰度变得异常重要。

现在项目已经稳定运行了一段时间,这套架构也经受住了考验。希望这篇笔记里提到的思路和代码片段,能帮你少走些弯路。如果你在实现过程中遇到其他问题,欢迎一起交流。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐