使用 Element Plus X + DeepSeek API 搭建 AI 对话应用

如果你正在寻找一种快速、优雅的方式来构建一个类似 ChatGPT 的 AI 对话界面,那么 Element Plus X 绝对值得一试。它是基于 Vue 3 和 Element Plus 的扩展组件库,专门为 AI 对话场景设计,提供了开箱即用的消息列表(BubbleList)、智能输入框(Sender)、会话管理、流式响应支持等组件。

本教程将带你一步步使用 Element Plus X 搭建一个完整的 AI 对话应用,并直接调用 DeepSeek 的开放 API(无需自己写后端)。你将在半小时内拥有一个功能完善的 AI 聊天界面,并体验流式逐字输出、Markdown 渲染等高级特性。

特别提醒:本教程为了快速演示,采用前端直接调用 DeepSeek API 的方式。这会暴露你的 API Key,存在安全风险,请勿在生产环境中使用! 生产环境建议通过后端代理转发请求。后文会给出安全最佳实践。


1. 环境准备

确保你的开发环境满足以下要求:

  • Node.js 16.0 或更高版本
  • 包管理工具(推荐 pnpm,也可以用 npm 或 yarn)

使用 Vite 创建一个新的 Vue 3 项目(如果你已经有现有项目,可以跳过此步):

pnpm create vite ai-chat-demo --template vue
cd ai-chat-demo
pnpm install

2. 安装 Element Plus X

Element Plus X 需要 element-plus 作为对等依赖,所以需要同时安装两者:

pnpm add element-plus vue-element-plus-x

在 main.js 中引入 Element Plus 的样式(Element Plus X 的组件已内置样式,无需单独引入):

import { createApp } from 'vue'
import App from './App.vue'
import 'element-plus/dist/index.css'  // 引入 Element Plus 基础样式

createApp(App).mount('#app')

3. 基础对话界面搭建

我们将创建一个名为 ChatView.vue 的组件,它包含两个核心部分:

  • BubbleList:用于展示对话气泡列表
  • Sender:用于输入和发送消息

3.1 组件结构

创建 src/components/ChatView.vue,写入以下基础模板:

<template>
  <div class="chat-container">
    <!-- 消息列表 -->
    <BubbleList
      ref="bubbleListRef"
      :list="messageList"
      :max-height="600"
      :virtual-scroll="messageList.length > 100"
      :item-size="80"
    >
      <template #loading>
        <Thinking :loading="isLoading" :error="error" />
      </template>
    </BubbleList>

    <!-- 输入区域 -->
    <Sender
      v-model="inputText"
      @send="handleSend"
      :allow-speech="true"
      placeholder="输入消息,Enter 发送,Shift + Enter 换行"
      :disabled="isLoading"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { BubbleList, Sender, Thinking } from 'vue-element-plus-x'

// 消息列表
const messageList = ref([
  {
    key: 1,
    role: 'ai',
    content: '你好!我是 DeepSeek AI 助手,有什么可以帮你的吗?',
    avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
    isMarkdown: true,
    typing: true,
  },
])

const inputText = ref('')
const bubbleListRef = ref(null)
const isLoading = ref(false)  // 加载状态
const error = ref(null)       // 错误信息

// 当前正在接收的流消息 ID
let currentAiMessageKey = null

// 处理发送消息
const handleSend = async () => {
  const content = inputText.value.trim()
  if (!content || isLoading.value) return

  // 添加用户消息
  const userMessage = {
    key: Date.now(),
    role: 'user',
    content: content,
    avatar: 'https://avatars.githubusercontent.com/u/76239030?v=4',
  }
  messageList.value.push(userMessage)
  inputText.value = ''
  
  // 创建一条空的 AI 消息,准备接收流式内容
  currentAiMessageKey = Date.now() + 1
  messageList.value.push({
    key: currentAiMessageKey,
    role: 'ai',
    content: '',
    avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
    isMarkdown: true,
    typing: true,
  })
  
  scrollToBottom()
  
  // 开始调用 DeepSeek API
  isLoading.value = true
  error.value = null
  
  try {
    // 构建请求参数
    const messages = messageList.value
      .filter(msg => msg.key !== currentAiMessageKey) // 排除当前正在生成的这条
      .map(msg => ({
        role: msg.role === 'ai' ? 'assistant' : 'user',
        content: msg.content
      }))
    
    // 调用 DeepSeek API(流式模式)
    await fetchDeepSeekStream(messages)
    
  } catch (err) {
    console.error('API 调用失败:', err)
    error.value = err.message
    
    // 更新错误消息
    const aiMessage = messageList.value.find(msg => msg.key === currentAiMessageKey)
    if (aiMessage) {
      aiMessage.content = `❌ 请求失败:${err.message}`
      aiMessage.typing = false
    }
  } finally {
    isLoading.value = false
  }
}

// 调用 DeepSeek 流式 API
const fetchDeepSeekStream = async (messages) => {
  // ⚠️ 重要提示:在生产环境中,API Key 不应直接暴露在前端
  // 建议通过后端代理转发请求,或使用环境变量(但仍有风险)
  const API_KEY = import.meta.env.VITE_DEEPSEEK_API_KEY || '你的API密钥'
  const API_URL = 'https://api.deepseek.com/v1/chat/completions'
  
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-chat',  // 使用 deepseek 模型
      messages: messages,
      stream: true,            // 启用流式输出
      temperature: 0.7,        // 控制随机性
      max_tokens: 2000,        // 最大生成长度
    })
  })
  
  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}))
    throw new Error(errorData.error?.message || `HTTP ${response.status}`)
  }
  
  // 处理流式响应
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''  // 用于拼接不完整的 chunk
  
  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      
      // 解码当前 chunk
      const chunk = decoder.decode(value, { stream: true })
      buffer += chunk
      
      // 按行分割,处理 SSE 格式的数据
      const lines = buffer.split('\n')
      buffer = lines.pop() || ''  // 保留可能不完整的最后一行
      
      for (const line of lines) {
        const trimmedLine = line.trim()
        if (!trimmedLine || !trimmedLine.startsWith('data:')) continue
        
        const data = trimmedLine.slice(5).trim()  // 去掉 "data:" 前缀
        
        // 检查是否是结束标记
        if (data === '[DONE]') {
          // 流结束,关闭打字机效果
          const aiMessage = messageList.value.find(msg => msg.key === currentAiMessageKey)
          if (aiMessage) {
            aiMessage.typing = false
          }
          currentAiMessageKey = null
          continue
        }
        
        try {
          const parsed = JSON.parse(data)
          const delta = parsed.choices?.[0]?.delta?.content
          
          if (delta) {
            // 找到当前 AI 消息并追加内容
            const aiMessage = messageList.value.find(msg => msg.key === currentAiMessageKey)
            if (aiMessage) {
              aiMessage.content += delta
              scrollToBottom()
            }
          }
        } catch (e) {
          // 忽略解析错误,可能是部分数据
          console.warn('解析 chunk 失败:', data)
        }
      }
    }
  } finally {
    reader.releaseLock()
  }
}

// 滚动到底部
const scrollToBottom = () => {
  setTimeout(() => {
    bubbleListRef.value?.scrollToBottom()
  }, 50)
}
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  border: 1px solid #e4e7ed;
  border-radius: 8px;
  overflow: hidden;
  background: #fff;
}
</style>

3.2 在 App.vue 中使用

修改 App.vue 让聊天组件占满视口:

<template>
  <div class="app">
    <ChatView />
  </div>
</template>

<script setup>
import ChatView from './components/ChatView.vue'
</script>

<style>
html, body, #app {
  margin: 0;
  padding: 0;
  height: 100%;
}
.app {
  height: 100%;
  padding: 20px;
  box-sizing: border-box;
}
</style>

现在运行 pnpm run dev,你应该能看到一个基本的聊天界面,可以发送消息并收到模拟回复。但我们还没有对接真实的 API,所以消息发送后会创建空白的 AI 消息并等待流式返回。接下来我们配置 DeepSeek API。


4. 获取 DeepSeek API 密钥

  1. 访问 DeepSeek 开放平台
  2. 注册/登录后,在控制台找到 API Keys 页面
  3. 点击 创建 API Key,复制生成的密钥(注意:密钥只在创建时显示一次,请妥善保存)

5. 配置环境变量(开发环境)

在项目根目录创建 .env 文件(或 .env.local),写入你的 API Key:

VITE_DEEPSEEK_API_KEY=sk-你的API密钥

然后在代码中通过 import.meta.env.VITE_DEEPSEEK_API_KEY 获取。注意: 以 VITE_ 开头的变量会被 Vite 注入到客户端代码中,因此仍然暴露在浏览器中。这只是为了开发方便,不要提交到公开仓库。


6. 测试对话

重新启动开发服务器,输入一条消息并发送,你应该会看到 AI 逐字输出回复,并且 Markdown 格式会被正确渲染。整个过程无需后端,完全在前端完成。


⚠️ 重要安全提醒:为什么不能在前端直接调用?

虽然上述代码可以工作,但存在严重的安全隐患:

  • 所有前端代码(包括环境变量中的 API Key)都对用户可见。用户可以通过浏览器开发者工具轻松查看你的 API Key。
  • 恶意用户可能盗用你的 Key 调用 DeepSeek API,造成不必要的费用消耗甚至账户风险。

因此,本教程的方法仅适用于本地开发或个人学习,绝对不要部署到公开网络!


🛡️ 生产环境最佳实践:后端代理转发

正确的做法是在你的服务器上搭建一个轻量后端(或使用无服务器函数),由后端持有 API Key 并转发请求。前端调用自己的接口,后端再调用 DeepSeek API。这样 API Key 永远不会暴露给客户端。

示例:使用 Vercel Serverless Functions 作为代理

  1. 在项目根目录创建 api/chat.js:
export default async function handler(req, res) {
  // 只允许 POST 请求
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  const API_KEY = process.env.DEEPSEEK_API_KEY  // 在 Vercel 后台设置环境变量
  const API_URL = 'https://api.deepseek.com/v1/chat/completions'

  try {
    const response = await fetch(API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${API_KEY}`
      },
      body: JSON.stringify(req.body)
    })

    // 设置响应头以支持流式输出
    res.setHeader('Content-Type', 'text/event-stream;charset=utf-8')
    res.setHeader('Cache-Control', 'no-cache')
    res.setHeader('Connection', 'keep-alive')

    // 将 DeepSeek 的流式响应直接 pipe 给前端
    const reader = response.body.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      const chunk = decoder.decode(value)
      res.write(chunk)
    }
    res.end()
  } catch (error) {
    res.status(500).json({ error: error.message })
  }
}
  1. 在前端将请求地址改为 /api/chat 而不是直接调用 DeepSeek 的地址。

  2. 在 Vercel 项目设置中添加环境变量 DEEPSEEK_API_KEY。

这样,API Key 安全地存储在服务器端,前端只需调用同域接口,安全又简单。其他平台(Netlify Functions、Cloudflare Workers 等)也有类似方案。


7. 更多增强功能

7.1 Markdown 与代码高亮

BubbleList 的 isMarkdown 属性已开启 Markdown 渲染。如果需要代码高亮,可以引入 highlight.js 或 prismjs,并在全局配置。例如:

pnpm add highlight.js

然后在 main.js 中:

import 'highlight.js/styles/github.css'

7.2 附件上传

Sender 组件支持附件上传,通过 attachments 属性开启:

<Sender
  v-model="inputText"
  @send="handleSend"
  :attachments="true"
  @attachments-change="handleAttachments"
/>

在 handleAttachments 中你可以将文件上传到自己的服务器,获取 URL 后作为消息内容的一部分发送给 AI(如果你的模型支持多模态)。

7.3 长对话性能优化

当对话历史非常长时,启用虚拟滚动可以大幅提升性能:

<BubbleList
  :list="messageList"
  :virtual-scroll="true"
  :threshold="100"
  :item-size="80"
/>

item-size 是每个消息项的预估高度,用于计算滚动条。如果你的消息高度变化较大,可能需要使用动态高度模式。


8. 部署你的 AI 对话应用(安全版)

按照上述后端代理方案,你可以将项目部署到 Vercel、Netlify 等平台。构建命令仍然是:

pnpm run build

部署时记得设置环境变量 DEEPSEEK_API_KEY。


9. 总结

通过本教程,你学会了:

  • 如何使用 Element Plus X 快速搭建 AI 对话界面
  • 如何调用 DeepSeek API 实现流式对话
  • 为什么不能在前端暴露 API Key,以及如何通过后端代理安全地调用

Element Plus X 还提供了更多高级组件(会话管理、提示词、思维链等),你可以根据需求进一步扩展。希望这篇教程能帮助你快速上手 AI 对话应用的开发!

如果你在实践中遇到问题,欢迎在评论区留言讨论。Happy coding! 🚀

posted @ 2026-03-11 16:47  战立标  阅读(317)  评论(0)    收藏  举报