最近在做一个电商项目,需要集成客服功能。传统的客服系统用起来总感觉差点意思:用户问个问题,要么等半天,要么机器人答非所问,推荐的问题也经常是“牛头不对马嘴”。琢磨了一下,决定用 Vue 3 从头搭建一个更“聪明”的对话框,并且把 AI 辅助生成推荐问题的能力也整合进去。折腾完感觉收获不小,把整个过程和踩过的坑记录一下。

1. 背景与痛点:为什么需要更智能的客服对话框?

以前项目里用的客服模块,大多是现成的 SaaS 服务或者比较简单的开源组件,普遍存在几个让人头疼的问题:

  • 响应延迟与交互生硬:很多是基于轮询(Polling)或者简单的长连接,消息一来一回有明显的延迟感,对话不流畅。对话框本身的 UI 交互也比较生硬,弹出/关闭、消息滚动体验不佳。
  • 推荐问题“鸡同鸭讲”:这是最核心的痛点。很多系统推荐的问题是静态配置的,或者基于非常简单的关键词匹配。比如用户刚问完“我的订单什么时候发货?”,系统可能还在推荐“如何注册账号?”这种完全不相关的问题,用户体验很差。
  • 状态管理混乱:对话历史、用户输入状态、客服连接状态、推荐问题列表……这些状态如果散落在各个组件里,维护起来简直是噩梦,尤其是需要持久化或跨组件同步时。
  • 扩展性差:当想加入“正在输入”提示、消息撤回、富媒体消息(图片/文件)或者与后端 AI 服务深度集成时,原有的架构往往牵一发而动全身。

所以,这次的目标很明确:构建一个响应快、推荐准、状态清晰且易于扩展的智能客服对话组件。

2. 技术选型:Vue 3 + WebSocket + AI 服务,为什么是它们?

面对这些痛点,技术选型上主要考虑了以下几个方案:

  • 纯前端静态方案:所有逻辑和推荐问题写死在前端。优点是简单、快,但完全无法解决“智能推荐”和“实时对话”的核心需求,首先被排除。
  • 传统 Ajax + 轮询:这是老方案了,延迟高、服务器压力大,不适合实时性要求高的对话场景。
  • Vue 3 + WebSocket + NLP API:这是我们最终选择的方案。
    • Vue 3 (Composition API):提供了更灵活、逻辑内聚的代码组织方式,特别适合封装复杂的对话框逻辑(如消息处理、推荐算法)。响应式系统与 TypeScript 结合完美,类型提示和代码可维护性大大提升。
    • WebSocket:实现真正的全双工实时通信,消息即发即收,配合心跳包和断线重连机制,可以打造非常流畅的对话体验。
    • NLP (自然语言处理) API:这是实现“智能”的关键。我们不需要自己从头训练模型,而是集成成熟的云服务(如国内各大云厂商提供的 NLP 能力,或开源模型 API),对用户当前对话内容进行语义分析,从而动态生成最相关的推荐问题。

这个组合既能保证前端体验的流畅和可维护性,又能通过后端 AI 能力赋予系统真正的“智能”。

3. 核心实现拆解

整个实现可以分为三大部分:对话框 UI、状态管理和智能推荐。

3.1 使用 Vue 3 Teleport 构建模态对话框

对话框需要渲染在 body 根部,避免父组件样式影响。Vue 3 的 Teleport 组件是为此而生的。

<!-- SmartChatDialog.vue -->
<template>
  <Teleport to="body">
    <div v-if="modelValue" class="chat-dialog-overlay" @click.self="handleClose">
      <div class="chat-dialog-container">
        <!-- 对话框头部 -->
        <div class="dialog-header">
          <h3>智能客服</h3>
          <button @click="handleClose">×</button>
        </div>
        <!-- 消息列表区域 -->
        <div class="message-list" ref="messageListRef">
          <div v-for="msg in messageList" :key="msg.id" :class="['message-item', `message-${msg.type}`]">
            {{ msg.content }}
          </div>
          <!-- 骨架屏,在加载推荐问题时显示 -->
          <div v-if="recommendLoading" class="recommend-skeleton">
            <div class="skeleton-line"></div>
            <div class="skeleton-line"></div>
          </div>
        </div>
        <!-- 推荐问题区域 -->
        <div class="recommend-questions" v-if="recommendQuestions.length > 0">
          <div class="recommend-title">猜你想问:</div>
          <div class="question-tags">
            <span v-for="(q, idx) in recommendQuestions" :key="idx" class="question-tag" @click="sendQuestion(q)">
              {{ q }}
            </span>
          </div>
        </div>
        <!-- 输入区域 -->
        <div class="input-area">
          <input v-model="userInput" @keyup.enter="sendMessage" placeholder="请输入您的问题..." />
          <button @click="sendMessage">发送</button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
// 假设我们使用 Pinia 来管理状态
import { useChatStore } from '@/stores/chat';

interface Message {
  id: string;
  content: string;
  type: 'user' | 'bot';
  timestamp: number;
}

const props = defineProps<{
  modelValue: boolean; // 控制对话框显示隐藏
}>();
const emit = defineEmits<{
  'update:modelValue': [value: boolean];
}>();

const chatStore = useChatStore();
const messageListRef = ref<HTMLElement>();
const userInput = ref('');
const recommendLoading = ref(false);

// 从 store 中获取状态
const { messageList, recommendQuestions } = storeToRefs(chatStore);

const handleClose = () => {
  emit('update:modelValue', false);
};

const sendMessage = async () => {
  const content = userInput.value.trim();
  if (!content) return;
  // 1. 将用户消息添加到列表
  chatStore.addUserMessage(content);
  userInput.value = '';
  // 2. 滚动到底部
  scrollToBottom();
  // 3. 触发获取AI回复和推荐问题的逻辑
  recommendLoading.value = true;
  await chatStore.fetchBotResponse(content);
  recommendLoading.value = false;
  scrollToBottom();
};

// 点击推荐问题直接发送
const sendQuestion = (question: string) => {
  userInput.value = question;
  sendMessage();
};

// 保持消息列表滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (messageListRef.value) {
      messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
    }
  });
};

// 监听对话框打开,可以在这里初始化历史记录
watch(() => props.modelValue, (newVal) => {
  if (newVal) {
    chatStore.loadHistory();
    nextTick(scrollToBottom);
  }
});
</script>
3.2 基于 Pinia 管理对话状态机

所有与对话相关的状态和逻辑都集中到 Pinia Store 中,保持组件轻薄。

// stores/chat.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { chatWebSocket } from '@/utils/websocket';
import { fetchRecommendations } from '@/api/nlp';
import { encrypt, decrypt } from '@/utils/crypto';

export const useChatStore = defineStore('chat', () => {
  // 状态
  const messageList = ref<Message[]>([]);
  const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting');
  const recommendQuestions = ref<string[]>([]);

  // Getter
  const lastUserMessage = computed(() => {
    const msgs = messageList.value;
    for (let i = msgs.length - 1; i >= 0; i--) {
      if (msgs[i].type === 'user') return msgs[i].content;
    }
    return '';
  });

  // Actions
  const addUserMessage = (content: string) => {
    const msg: Message = {
      id: `user_${Date.now()}`,
      content,
      type: 'user',
      timestamp: Date.now(),
    };
    messageList.value.push(msg);
    // 消息持久化到本地存储(加密)
    saveHistoryToLocal();
  };

  const addBotMessage = (content: string) => {
    const msg: Message = {
      id: `bot_${Date.now()}`,
      content,
      type: 'bot',
      timestamp: Date.now(),
    };
    messageList.value.push(msg);
    saveHistoryToLocal();
  };

  const fetchBotResponse = async (userMessage: string) => {
    // 1. 通过 WebSocket 发送消息给后端,获取客服回复
    chatWebSocket.send(JSON.stringify({ type: 'user_message', content: userMessage }));
    // 注意:实际回复是通过 WebSocket onmessage 事件监听,调用 addBotMessage 添加。
    // 这里假设一个异步函数模拟等待回复。
    // 2. 同时,基于用户最后一条消息,获取AI推荐问题
    await updateRecommendQuestions(userMessage);
  };

  const updateRecommendQuestions = async (context: string) => {
    try {
      // 调用 NLP API,传入当前对话上下文
      const response = await fetchRecommendations(context);
      // 假设 API 返回 { questions: string[] }
      recommendQuestions.value = response.questions.slice(0, 5); // 只取前5个
    } catch (error) {
      console.error('获取推荐问题失败:', error);
      recommendQuestions.value = []; // 失败时清空推荐
    }
  };

  // 本地历史记录加密存储
  const saveHistoryToLocal = () => {
    const data = encrypt(JSON.stringify(messageList.value));
    localStorage.setItem('chat_history', data);
  };

  const loadHistory = () => {
    const data = localStorage.getItem('chat_history');
    if (data) {
      try {
        messageList.value = JSON.parse(decrypt(data));
      } catch (e) {
        console.error('读取历史记录失败:', e);
        messageList.value = [];
      }
    }
  };

  // WebSocket 事件监听(通常在应用初始化时调用)
  const initWebSocket = () => {
    chatWebSocket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === 'bot_message') {
        addBotMessage(data.content);
      }
      // 可以处理其他类型,如连接状态更新
    };
    chatWebSocket.onopen = () => (connectionStatus.value = 'connected');
    chatWebSocket.onclose = () => (connectionStatus.value = 'disconnected');
  };

  return {
    messageList,
    connectionStatus,
    recommendQuestions,
    lastUserMessage,
    addUserMessage,
    addBotMessage,
    fetchBotResponse,
    updateRecommendQuestions,
    loadHistory,
    initWebSocket,
  };
});
3.3 推荐问题算法设计(前端辅助处理)

虽然核心的语义理解交给后端 AI API,但前端也可以做一些轻量级的预处理或备选方案,比如在 AI 服务不可用时,使用基于文本相似度的简易算法。

// utils/recommendation.ts
/**
 * 简易前端推荐算法(TF-IDF余弦相似度思路简化版)
 * 用于在无AI服务时降级使用,或对AI结果进行二次过滤。
 * @param userInput 用户当前输入
 * @param questionPool 问题池(从后端获取或本地配置)
 * @param topK 返回前几个推荐
 */
export function getSimpleRecommendations(
  userInput: string,
  questionPool: string[],
  topK: number = 3
): string[] {
  // 1. 中文分词(这里用简单按字拆分,生产环境应用成熟分词库如 `jieba-js`)
  const tokenize = (text: string): string[] => {
    // 移除标点,按字符分割(简易处理)
    return text.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, ' ').split(/\s+/).filter(Boolean);
  };

  const inputTokens = tokenize(userInput);

  // 2. 计算每个问题的相似度得分
  const scores = questionPool.map((question) => {
    const questionTokens = tokenize(question);
    // 计算 Jaccard 相似度(简易版,替代余弦相似度)
    const union = new Set([...inputTokens, ...questionTokens]);
    const intersection = new Set(inputTokens.filter((t) => questionTokens.includes(t)));
    return intersection.size / union.size;
  });

  // 3. 获取得分最高的 topK 个问题的索引
  const indexedScores = scores.map((score, index) => ({ score, index }));
  indexedScores.sort((a, b) => b.score - a.score);
  const topIndices = indexedScores.slice(0, topK).map((item) => item.index);

  // 4. 返回对应的问题
  return topIndices.map((idx) => questionPool[idx]).filter((q) => q);
}

// 在 Store 的 updateRecommendQuestions 中可以作为降级方案
// if (aiServiceAvailable) { call AI API } else { recommendQuestions.value = getSimpleRecommendations(context, localQuestionPool); }

4. 生产环境下的重要考量

功能实现后,要上线还需要考虑很多工程化问题。

4.1 对话历史本地存储加密方案

聊天记录可能包含敏感信息。直接存 localStorage 是明文的,不安全。

  • 方案:使用 crypto-js 或 Web Crypto API 进行对称加密(如 AES)。
  • 密钥管理:密钥可以由后端在用户登录后下发,并存储在内存中(或安全的 sessionStorage),避免硬编码在前端代码里。
  • 代码示例(简化版):
import CryptoJS from 'crypto-js';

const SECRET_KEY = import.meta.env.VITE_CHAT_SECRET_KEY; // 从环境变量获取,应由后端动态提供

export function encrypt(text: string): string {
  return CryptoJS.AES.encrypt(text, SECRET_KEY).toString();
}

export function decrypt(cipherText: string): string {
  const bytes = CryptoJS.AES.decrypt(cipherText, SECRET_KEY);
  return bytes.toString(CryptoJS.enc.Utf8);
}
4.2 API 调用限流策略

推荐问题的 AI API 通常是按次收费或有 QPS 限制的,需要防止用户快速输入导致频繁调用。

  • 方案:在触发推荐问题更新的地方(如 fetchBotResponse 中调用 updateRecommendQuestions 前)加入防抖(Debounce)或节流(Throttle)。
  • 选择:对于推荐问题,更适合用防抖,即在用户停止输入一段时间(如 500ms)后再去调用 API,避免中间过程的无意义调用。
import { debounce } from 'lodash-es';

// 在 Store 中
const updateRecommendQuestionsDebounced = debounce(async (context: string) => {
  // ... 原有的 API 调用逻辑
}, 500);

// 在 fetchBotResponse 中调用
await updateRecommendQuestionsDebounced(userMessage);
4.3 移动端适配注意事项
  • 视口与布局:使用 viewport meta 标签,对话框宽度使用 max-width: 100%vw 单位,高度使用 vh 单位,确保在不同屏幕下显示正常。
  • 输入法遮挡:在移动端,聚焦输入框时键盘弹起可能会遮挡对话框。可以通过监听 resize 事件或 VisualViewport API,动态调整对话框位置或滚动消息列表。
  • 触摸反馈:为推荐问题的标签(question-tag)添加 :active 样式或使用轻量级触觉反馈库,提升触摸体验。

5. 避坑指南与优化点

实际开发中遇到了不少坑,这里总结几个关键的。

5.1 Vue 响应式数据在 WebSocket 场景下的优化

WebSocket 消息是异步、高频的。如果直接将大量消息对象推入 messageList 这个响应式数组,可能会导致不必要的全量渲染,在消息快速滚动时卡顿。

  • 优化
    1. 使用 shallowRefmarkRaw:如果消息对象本身结构稳定,内部属性不需要响应式,可以用 shallowRef 包裹数组,或者给每个消息对象用 markRaw 标记。
    2. 虚拟列表:如果对话历史可能非常长(比如支持查看多天记录),需要实现虚拟列表,只渲染可视区域的消息。可以使用 vue-virtual-scroller 等库。
    3. 分批更新:不要每条消息都立即触发更新和滚动。可以设置一个微任务队列,将短时间内的多条消息合并为一次更新。
5.2 中文分词的特殊处理

在前端做简易文本相似度计算时,分词是关键一步。英文有天然的空格分隔,中文则需要专门处理。

  • 不要自己造轮子:用简单的按字拆分(split(''))效果很差,因为中文词汇有语义。例如,“苹果手机”按字拆会失去“苹果”和“手机”这两个词元。
  • 推荐方案:使用轻量级的前端中文分词库,比如 jieba-js(结巴分词的 JavaScript 移植版)。它可以在浏览器中运行,提供基本的分词功能。
  • 注意包体积:这类库可能不小,如果只是备用方案,可以考虑异步动态加载(import()),或者让后端分词后返回词元数组。
5.3 异步加载的骨架屏实现

在等待 AI 推荐问题返回时,如果直接隐藏推荐区域,界面会跳动。显示一个骨架屏能提升体验。

  • 实现:如前面组件代码所示,在 recommendLoadingtrue 时,渲染一个带有 CSS 动画的骨架屏占位符。
  • CSS 关键点
    .skeleton-line {
      height: 20px;
      background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
      background-size: 200% 100%;
      animation: loading 1.5s infinite;
      border-radius: 4px;
      margin-bottom: 8px;
    }
    @keyframes loading {
      0% { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }
    

6. 总结与思考

通过 Vue 3 的组合式 API 和 Pinia,我们构建了一个结构清晰、状态管理完善的智能客服对话框。利用 WebSocket 保证了实时性,集成 AI NLP 服务实现了真正有意义的推荐问题。过程中,从 UI 渲染优化、数据安全到性能限流,都做了相应的考虑。

智能客服对话框示意图

最后,抛出一个值得持续思考的问题:我们如何科学地评估这个“智能”推荐算法的准确率? 仅仅靠线上用户的点击率(CTR)够吗?是否需要在后台建立一套标注和 A/B 测试系统,对比不同算法策略(如纯关键词匹配 vs. 语义理解)下,推荐问题被用户采纳后,最终解决用户问题的成功率?这对于持续优化客服系统的“智商”至关重要。

这次实践让我深刻体会到,前端不仅仅是画界面,当需要处理复杂的实时数据流、状态逻辑并与 AI 能力结合时,一个良好的架构设计是多么重要。希望这篇笔记对你有帮助。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐