ChatGPT与Vue集成实战:从零构建智能对话应用

最近在捣鼓一个个人项目,想给Vue应用加上智能对话的能力,类似一个网页版的AI助手。本以为调用个API就完事了,结果踩了不少坑,从状态管理混乱到响应卡顿,问题一个接一个。经过一番折腾,总算梳理出了一套相对完整的集成方案。今天就把我的实战经验分享出来,希望能帮到同样想在前端玩转AI的开发者朋友们。

1. 背景与痛点:为什么集成ChatGPT没那么简单?

刚开始的时候,我的想法很简单:用户输入文本,我调用ChatGPT API,拿到回复后显示出来。但真做起来,发现远不是发个HTTP请求那么简单。

状态管理的混乱:对话应用天然就是多状态的。用户消息、AI回复、加载状态、错误信息、历史记录……这些状态如果散落在各个组件里,很快就会变得难以维护。一个简单的“重新生成回答”功能,都可能涉及到多个状态的联动更新。

API调用延迟与用户体验:ChatGPT的API响应时间并不稳定,有时快有时慢。如果界面在等待响应时完全卡住,用户会以为应用崩溃了。如何优雅地处理加载状态,甚至在等待时提供一些即时反馈(比如显示“正在思考…”),是个需要仔细设计的问题。

响应式数据同步的挑战:Vue的响应式系统很棒,但和异步的流式响应(Streaming Response)结合时,需要一些技巧。理想情况下,我们希望AI的回复能够像真人打字一样,一个字一个字地“流”出来,而不是等全部生成完再一次性显示。这要求我们能实时处理API返回的数据流,并同步更新到界面上。

安全与成本控制:前端直接调用API意味着API Key会暴露在客户端,存在泄露风险。同时,如果不加控制,用户可能频繁发送请求,导致不必要的API费用激增。

2. 技术选型:如何搭建稳健的架构?

面对这些问题,我对比了几种方案,最终确定了以下技术栈。

直接调用API vs 使用后端中间层

  • 直接调用(初期方案):简单粗暴,前端axios直接请求https://api.openai.com/v1/chat/completions。优点是开发快,缺点非常明显:API Key暴露、无法做复杂的请求预处理或后处理、受浏览器CORS政策限制。
  • 后端中间层(推荐方案):前端请求自己的后端服务器,由后端服务器携带API Key去请求OpenAI。这是生产环境的标配。它解决了安全问题,还能在后端实现限流、日志记录、费用分摊、提示词(Prompt)统一管理等高级功能。本文为了聚焦前端,示例代码会模拟直接调用,但强烈建议在实际项目中采用此方案

为什么选择Composition API + Pinia? Vue 3的Composition API让我能将与ChatGPT交互的逻辑(获取回复、管理状态、处理流)封装到一个独立的、可复用的composable函数(例如useChatGPT)中。这让我的组件代码非常干净,只关注视图渲染。

而状态管理,我选择了Pinia。相比Vuex,Pinia的语法更简洁,对TypeScript的支持更好,并且完美契合Composition API的思想。我将所有对话相关的状态(消息列表、当前模型参数、加载状态)都放在一个Pinia Store里,使得任何组件都能轻松访问和修改,逻辑清晰。

3. 核心实现:一步步构建对话引擎

3.1 封装API调用层

首先,我们封装一个专门的服务模块来处理所有与AI后端的通信。这里假设我们有一个中间层后端,端点为/api/chat

// src/services/chatService.js
import axios from 'axios';

const apiClient = axios.create({
  baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:3000',
  timeout: 30000, // 设置一个较长的超时,因为AI生成可能需要时间
});

export const chatService = {
  async sendMessage(messages, options = {}) {
    // messages 格式: [{ role: 'user', content: '你好' }, { role: 'assistant', content: '你好!' }]
    const payload = {
      messages,
      model: options.model || 'gpt-3.5-turbo',
      stream: options.stream || false, // 是否启用流式响应
      ...options,
    };

    try {
      const response = await apiClient.post('/api/chat', payload);
      return response.data;
    } catch (error) {
      console.error('Chat API请求失败:', error);
      // 这里可以处理不同类型的错误(网络错误、4xx、5xx等)
      throw error;
    }
  },
};

3.2 基于Pinia的状态管理

接下来,创建Pinia Store来集中管理对话状态。

// src/stores/chatStore.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { chatService } from '@/services/chatService';

export const useChatStore = defineStore('chat', () => {
  // 状态
  const messages = ref([]); // 消息列表
  const isLoading = ref(false);
  const error = ref(null);
  const currentModel = ref('gpt-3.5-turbo');

  // Getter
  const conversationHistory = computed(() => messages.value);

  // Action
  async function sendUserMessage(content) {
    // 添加用户消息到列表
    const userMessage = { role: 'user', content, id: Date.now() };
    messages.value.push(userMessage);

    isLoading.value = true;
    error.value = null;

    try {
      // 准备发送给API的历史消息
      const historyForApi = messages.value.map(({ role, content }) => ({ role, content }));

      // 调用API
      const response = await chatService.sendMessage(historyForApi, {
        model: currentModel.value,
        stream: false, // 先实现非流式
      });

      // 添加AI回复到列表
      const aiMessage = {
        role: 'assistant',
        content: response.choices[0]?.message?.content || '(无回复)',
        id: Date.now() + 1,
      };
      messages.value.push(aiMessage);
    } catch (err) {
      error.value = err.message || '请求失败,请重试';
      // 可选:移除刚才添加的用户消息,或者标记为失败
      // messages.value.pop();
    } finally {
      isLoading.value = false;
    }
  }

  function clearConversation() {
    messages.value = [];
    error.value = null;
  }

  function setModel(model) {
    currentModel.value = model;
  }

  return {
    // 状态
    messages,
    isLoading,
    error,
    currentModel,
    // Getter
    conversationHistory,
    // Action
    sendUserMessage,
    clearConversation,
    setModel,
  };
});

3.3 实现流式响应处理(进阶)

流式响应能极大提升用户体验。这需要后端也支持流式返回(SSE或类似技术)。前端处理方式如下:

// 在 chatService.js 中增加流式方法
export const chatService = {
  // ... 之前的 sendMessage 方法

  async sendMessageStream(messages, options = {}, onChunk) {
    // onChunk 是一个回调函数,用于处理接收到的每一个数据块
    const payload = { ...options, messages, stream: true };

    try {
      const response = await fetch(`${this.baseURL}/api/chat-stream`, { // 假设另一个流式端点
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });

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

      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // 最后一行可能是不完整的,留到下次

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6);
            if (data === '[DONE]') break;
            try {
              const parsed = JSON.parse(data);
              // 调用回调,将新的内容片段传递出去
              onChunk(parsed.choices[0]?.delta?.content || '');
            } catch (e) {
              console.error('解析流数据失败:', e, data);
            }
          }
        }
      }
    } catch (error) {
      console.error('流式请求失败:', error);
      throw error;
    }
  },
};

// 在 chatStore.js 的 Action 中整合流式处理
async function sendUserMessageStream(content) {
  const userMessage = { role: 'user', content, id: Date.now() };
  messages.value.push(userMessage);

  isLoading.value = true;
  error.value = null;

  // 先创建一个空的AI消息占位
  const aiMessageId = Date.now() + 1;
  const aiMessage = { role: 'assistant', content: '', id: aiMessageId };
  messages.value.push(aiMessage);

  try {
    const historyForApi = messages.value
      .slice(0, -1) // 不包含刚刚添加的占位消息
      .map(({ role, content }) => ({ role, content }));

    await chatService.sendMessageStream(
      historyForApi,
      { model: currentModel.value },
      (chunk) => {
        // 找到那个占位消息,并追加内容
        const targetMsg = messages.value.find(msg => msg.id === aiMessageId);
        if (targetMsg) {
          targetMsg.content += chunk;
        }
      }
    );
  } catch (err) {
    error.value = err.message;
    // 如果出错,可以移除占位消息或更新其内容为错误信息
    const targetMsgIndex = messages.value.findIndex(msg => msg.id === aiMessageId);
    if (targetMsgIndex > -1) {
      messages.value[targetMsgIndex].content = '【回复生成失败】';
    }
  } finally {
    isLoading.value = false;
  }
}

4. 完整代码示例:组装聊天界面

4.1 主聊天组件 (ChatWindow.vue)

<template>
  <div class="chat-window">
    <div class="messages-container">
      <MessageItem
        v-for="message in chatStore.messages"
        :key="message.id"
        :message="message"
      />
      <div v-if="chatStore.isLoading" class="loading-indicator">
        思考中...
      </div>
      <div v-if="chatStore.error" class="error-alert">
        {{ chatStore.error }}
      </div>
    </div>

    <MessageInput @send="handleSend" :disabled="chatStore.isLoading" />
  </div>
</template>

<script setup>
import { useChatStore } from '@/stores/chatStore';
import MessageItem from './MessageItem.vue';
import MessageInput from './MessageInput.vue';

const chatStore = useChatStore();

const handleSend = (content) => {
  if (!content.trim()) return;
  // 使用流式方法
  chatStore.sendUserMessageStream(content);
  // 或使用普通方法:chatStore.sendUserMessage(content);
};
</script>

<style scoped>
.chat-window {
  display: flex;
  flex-direction: column;
  height: 600px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
}
.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}
.loading-indicator {
  padding: 8px;
  color: #666;
  font-style: italic;
}
.error-alert {
  padding: 8px;
  background-color: #ffebee;
  color: #c62828;
  border-radius: 4px;
  margin-top: 8px;
}
</style>

4.2 消息展示组件 (MessageItem.vue)

<template>
  <div :class="['message', message.role]">
    <div class="avatar">{{ avatarText }}</div>
    <div class="content">
      <!-- 使用 v-html 需注意XSS风险,此处仅展示纯文本,实际内容应来自可信源 -->
      <!-- 如需渲染Markdown,可引入 marked 等库 -->
      <pre>{{ message.content }}</pre>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  message: {
    type: Object,
    required: true,
  },
});

const avatarText = computed(() => {
  return props.message.role === 'user' ? '你' : 'AI';
});
</script>

<style scoped>
.message {
  display: flex;
  margin-bottom: 16px;
}
.message.user {
  flex-direction: row-reverse;
}
.message.user .content {
  background-color: #007aff;
  color: white;
  margin-right: 8px;
}
.message.assistant .content {
  background-color: #f0f0f0;
  color: #333;
  margin-left: 8px;
}
.avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background-color: #ccc;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  flex-shrink: 0;
}
.content {
  max-width: 70%;
  padding: 10px 14px;
  border-radius: 18px;
  word-break: break-word;
}
.content pre {
  white-space: pre-wrap;
  font-family: inherit;
  margin: 0;
}
</style>

4.3 消息输入组件 (MessageInput.vue)

<template>
  <div class="message-input">
    <textarea
      ref="textareaRef"
      v-model="inputText"
      @keydown.enter.exact.prevent="handleSend"
      :disabled="disabled"
      placeholder="输入消息... (Enter发送,Shift+Enter换行)"
      rows="2"
    />
    <button @click="handleSend" :disabled="disabled || !inputText.trim()">
      发送
    </button>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue';

const props = defineProps({
  disabled: Boolean,
});
const emit = defineEmits(['send']);

const inputText = ref('');
const textareaRef = ref(null);

const handleSend = () => {
  const text = inputText.value.trim();
  if (!text) return;
  emit('send', text);
  inputText.value = '';
  // 发送后焦点回到输入框
  nextTick(() => {
    textareaRef.value?.focus();
  });
};
</script>

<style scoped>
.message-input {
  display: flex;
  border-top: 1px solid #e0e0e0;
  padding: 12px;
}
.message-input textarea {
  flex: 1;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  resize: none;
  font-family: inherit;
  font-size: 14px;
}
.message-input textarea:focus {
  outline: none;
  border-color: #007aff;
}
.message-input button {
  margin-left: 12px;
  padding: 0 20px;
  background-color: #007aff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.message-input button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

5. 性能优化与健壮性

节流与防抖:对于输入框的自动保存或实时搜索建议功能,可以使用防抖。但发送消息本身,更适合用“加载状态”禁用按钮来防止重复提交,而不是防抖。

本地缓存策略:使用localStorageIndexedDB缓存历史对话。可以在Pinia Store初始化时读取,并在每次对话更新后保存。注意设置存储上限和清理策略。

// 在 chatStore.js 中
function loadFromCache() {
  const cached = localStorage.getItem('vue-chat-history');
  if (cached) {
    try {
      messages.value = JSON.parse(cached);
    } catch (e) {
      console.error('读取缓存失败', e);
    }
  }
}
// 使用 watch 深度监听 messages 变化并保存
watch(() => JSON.stringify(messages.value), (newVal) => {
  localStorage.setItem('vue-chat-history', newVal);
}, { deep: true });

错误重试机制:对于网络波动导致的失败,可以实现简单的重试逻辑。

async function sendMessageWithRetry(content, maxRetries = 2) {
  let lastError;
  for (let i = 0; i < maxRetries; i++) {
    try {
      await sendUserMessage(content); // 调用原有的发送方法
      return; // 成功则退出
    } catch (err) {
      lastError = err;
      console.warn(`发送失败,第${i + 1}次重试...`, err);
      if (i < maxRetries - 1) {
        await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); // 延迟重试
      }
    }
  }
  // 所有重试都失败
  error.value = `发送失败: ${lastError.message}`;
}

6. 避坑指南

  1. CORS问题:如果前端直接调用第三方API(不推荐),会遇到CORS限制。解决方案只有配置后端代理或让API服务端设置正确的CORS头部。最佳实践始终是使用自己的后端中间层
  2. Token泄露风险:绝对不要将API Key硬编码在前端代码或环境变量中(VUE_APP_*)。任何发往客户端的代码都是公开的。必须通过后端服务器来中转请求。
  3. 提示词注入:如果应用允许用户输入的部分会被拼接到系统提示词(System Prompt)中,需要警惕用户可能输入精心设计的文本来“劫持”AI,使其忽略原有指令。需要对用户输入进行适当的过滤或转义。
  4. 处理过长的上下文:ChatGPT API有token数量限制。当对话轮数很多时,需要设计策略截断或总结历史消息,而不是无脑发送全部历史。可以在后端实现这部分逻辑。
  5. 流式响应中断处理:用户可能在AI回复过程中关闭页面或开始新对话。需要妥善管理fetchEventSource的连接,在组件卸载时主动中断流,避免内存泄漏和无效请求。

7. 进阶建议:从单轮对话到智能体

现在的应用已经是一个可用的聊天界面了。如何让它更强大?

  • 多轮对话与记忆:目前我们发送了全部历史。可以进阶为只发送最近N轮对话,或者使用更复杂的“记忆摘要”技术,将长对话压缩成关键点再提供给AI。
  • 工具调用(Function Calling):让AI不仅能聊天,还能执行操作。例如,用户说“明天北京天气怎么样?”,AI可以调用你提供的getWeather(location)函数,获得真实数据后再组织回复。这需要后端解析AI的请求,调用相应工具,并将结果返回给AI继续生成。
  • 构建专属知识库:结合RAG(检索增强生成)技术,让AI能基于你提供的私有文档(公司手册、个人笔记)来回答问题。这通常涉及文档切片、向量化存储、语义检索等后端流程。
  • 多模态:集成视觉模型,让应用能“看”图片并描述或分析。

将AI集成到前端应用,打开了一扇通往无数可能的大门。从简单的文本对话开始,逐步探索更复杂的交互模式,这个过程本身充满了乐趣和挑战。


在实践了上述Vue集成ChatGPT的全过程后,我对于如何为AI赋予“实时对话”能力产生了更浓厚的兴趣。文本交互很棒,但语音交互才是更自然的形态。这让我想起了之前体验过的一个非常有趣的动手实验——从0打造个人豆包实时通话AI

如果说本文是在教你怎么给应用装上“思考的大脑”,那么那个实验就是在教你如何同时为它加上“聆听的耳朵”和“说话的嘴巴”。它基于火山引擎的模型,带你完整走通实时语音识别(ASR)、大语言模型(LLM)对话、语音合成(TTS)的闭环。你最终能做出一个真正的、可以语音实时对话的Web应用,效果就像在和手机语音助手通话一样。

对于已经掌握了前端和AI API集成的开发者来说,那个实验是向更沉浸式、更强大AI应用迈进的自然下一步。我按照实验步骤操作了一遍,流程清晰,把复杂的语音流处理、WebSocket通信等细节都封装好了,主要精力可以放在逻辑和交互设计上,对于想快速体验语音AI落地的朋友来说非常友好。

Logo

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

更多推荐