实战干货:用 Vue 3 + TypeScript 打造一个现代化 AI 聊天助手组件(附代码示例与开发避坑指南)
适用场景:Vue 项目集成 AI 助手、Web 端智能客服、开发者工具辅助
🌟 引言:为什么你需要一个"悬浮球"AI 聊天助手?
在当今的 Web 应用中,AI 已经从"概念"走向"落地"。无论是帮助用户快速解决问题,还是提升产品交互体验,一个轻量、美观、可定制的 AI 聊天助手都能极大增强用户体验。
本文将带你从零开始,使用 Vue 3 + Composition API + TypeScript 构建一个功能完整的 悬浮式 AI 聊天助手组件,支持:
- 悬浮按钮触发
- 消息对话流
- Markdown 渲染
- 语音输入(Web Speech API)
- 请求重试与超时处理
- 历史记录持久化
- 自定义请求头和参数
- 可扩展的 UI 配置
我们将以实战为导向,分享每一个关键环节的实现细节,并总结开发过程中遇到的真实问题与解决方案。
✅ 最终效果预览

🔧 技术栈概览
| 技术 | 版本/说明 |
|---|---|
| Vue | 3.4+ (Composition API) |
| TypeScript | 5.x |
| Vite | 5.x |
| Tailwind CSS | 3.x |
| Axios | 1.x |
| Web Speech API | 浏览器原生支持 |
🚀 第一步:搭建基础结构
1. 创建组件目录结构
bash
src/
├── components/
│ └── AIAssistant.vue
├── composables/
│ ├── useAIChat.ts
│ └── useLocalStorage.ts
├── utils/
│ └── markdownRenderer.ts
└── types/
└── ai.d.ts
2. 定义类型(types/ai.d.ts)
ts
// types/ai.d.ts
export interface Message {
id: string;
content: string;
role: 'user' | 'assistant';
timestamp: Date;
}
export interface RequestConfig {
url: string;
headers?: Record<string, string>;
params?: Record<string, any>;
timeout?: number;
retryCount?: number;
}
💡 核心逻辑:使用 useAIChat 组合式函数
我们封装一个通用的组合式逻辑来管理聊天状态和请求流程。
composables/useAIChat.ts
ts
import { ref, computed } from 'vue';
import axios from 'axios';
import type { Message, RequestConfig } from '@/types/ai';
export function useAIChat(config: RequestConfig) {
const messages = ref<Message[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const addMessage = (message: Omit<Message, 'id' | 'timestamp'>) => {
messages.value.push({
...message,
id: Date.now().toString(),
timestamp: new Date(),
});
};
const sendMessage = async (content: string) => {
if (!content.trim()) return;
addMessage({ content, role: 'user' });
isLoading.value = true;
error.value = null;
try {
const response = await axios.post(
config.url,
{ prompt: content },
{
headers: config.headers,
params: config.params,
timeout: config.timeout || 10000,
// 重试机制
retry: config.retryCount || 3,
}
);
addMessage({
content: response.data.response || '回复内容获取失败',
role: 'assistant',
});
} catch (err: any) {
error.value = err.message || '网络请求失败,请稍后重试';
console.error(err);
} finally {
isLoading.value = false;
}
};
const clearMessages = () => {
messages.value = [];
};
return {
messages,
isLoading,
error,
sendMessage,
clearMessages,
};
}
✅ 难点突破 :如何优雅地处理请求失败?
我们通过
axios的拦截器或自定义retry逻辑实现自动重试。这里简化为传入retryCount,实际项目中可结合指数退避策略。
🖼️ UI 层实现:AIAssistant.vue
使用 Tailwind CSS 实现现代化布局
vue
<!-- components/AIAssistant.vue -->
<template>
<div class="fixed bottom-6 right-6 z-50">
<!-- 悬浮按钮 -->
<button
@click="togglePanel"
class="w-14 h-14 rounded-full bg-indigo-600 text-white flex items-center justify-center shadow-lg hover:bg-indigo-700 transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
aria-label="打开 AI 助手"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707-.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</button>
<!-- 聊天面板 -->
<transition name="slide-fade">
<div
v-if="isOpen"
class="absolute bottom-20 right-6 w-80 max-w-xs bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-in-out"
>
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-sm font-semibold text-gray-800">智能助手</h3>
<button @click="closePanel" class="text-gray-500 hover:text-gray-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- 内容区 -->
<div class="h-96 overflow-y-auto p-4 space-y-4">
<div v-for="msg in chat.messages" :key="msg.id" class="flex flex-col">
<div
:class="[
'px-4 py-2 rounded-lg max-w-xs',
msg.role === 'user'
? 'bg-blue-100 text-blue-800 ml-auto'
: 'bg-gray-100 text-gray-800 mr-auto'
]"
>
<div v-html="renderMarkdown(msg.content)" />
</div>
<span class="text-xs text-gray-500 mt-1">{{ formatTime(msg.timestamp) }}</span>
</div>
<!-- 加载状态 -->
<div v-if="chat.isLoading" class="flex justify-start">
<div class="flex items-center space-x-2 px-4 py-2 bg-gray-100 rounded-lg">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
<span class="text-gray-600 text-sm">正在思考...</span>
</div>
</div>
<!-- 错误提示 -->
<div v-if="chat.error" class="text-red-500 text-sm p-2 bg-red-50 rounded">
{{ chat.error }}
</div>
</div>
<!-- 输入区域 -->
<div class="p-4 border-t border-gray-200">
<div class="flex items-center space-x-2">
<button
@click="handleVoiceInput"
class="p-2 text-gray-500 hover:text-gray-700 rounded-full hover:bg-gray-100"
title="语音输入"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 013-3h.01C9.3 2 12 4.7 12 8v.01a3 3 0 01-3 3z" />
</svg>
</button>
<input
v-model="inputText"
@keypress.enter="send"
placeholder="请输入消息..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
maxlength="500"
/>
<button
@click="send"
:disabled="!inputText.trim() || chat.isLoading"
class="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
title="发送"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useAIChat } from '@/composables/useAIChat';
import { useLocalStorage } from '@/composables/useLocalStorage';
const inputText = ref('');
const isOpen = ref(false);
// 配置 AI 请求
const config: RequestConfig = {
url: '/api/chat', // 替换为你自己的 API 地址
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN',
},
params: {
model: 'gpt-3.5-turbo',
},
timeout: 15000,
retryCount: 3,
};
// 使用组合式逻辑
const chat = useAIChat(config);
// 控制面板显示
const togglePanel = () => {
isOpen.value = !isOpen.value;
};
const closePanel = () => {
isOpen.value = false;
};
const send = () => {
if (!inputText.value.trim()) return;
chat.sendMessage(inputText.value);
inputText.value = '';
};
const handleVoiceInput = () => {
if (!('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) {
alert('浏览器不支持语音识别');
return;
}
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.lang = 'zh-CN';
recognition.continuous = false;
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
inputText.value = transcript;
};
recognition.onerror = () => {
alert('语音识别失败');
};
recognition.start();
};
const renderMarkdown = (text: string) => {
// 简单的 Markdown 渲染(可替换为 marked 或 remark)
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>');
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
</script>
<style scoped>
.slide-fade-enter-active {
transition: all 0.3s ease;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 0);
}
.slide-fade-enter, .slide-fade-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>
🧠 开发中的难点与解决方案
❌ 难点一:语音输入在部分浏览器不支持
现象 :iOS Safari 不支持
SpeechRecognition,导致语音功能失效。
解决方案:
- 提供降级提示:"当前浏览器不支持语音输入"
- 使用
@media查询检测设备类型,隐藏语音按钮 - 推荐使用第三方库如 web-speech-api
ts
const isSpeechSupported = 'SpeechRecognition' in window || 'webkitSpeechRecognition' in window;
❌ 难点二:长文本渲染卡顿
现象:当聊天历史很长时,滚动性能下降,甚至卡死。
解决方案:
- 使用虚拟滚动(如
vue-virtual-scroll-list) - 限制历史记录条数(例如最多保留 50 条)
- 使用
IntersectionObserver懒加载旧消息
ts
// 在 useAIChat 中添加
const MAX_MESSAGES = 50;
if (messages.value.length > MAX_MESSAGES) {
messages.value = messages.value.slice(-MAX_MESSAGES);
}
❌ 难点三:请求频繁导致服务器压力大
现象:用户连续发送多个消息,造成大量并发请求。
解决方案:
- 添加防抖(debounce)控制发送频率
- 使用
throttle限制每秒最多 1 次请求
ts
import { debounce } from 'lodash';
const debouncedSend = debounce(send, 500);
❌ 难点四:跨域 CORS 问题
现象 :调用外部 AI API 时报错
Access-Control-Allow-Origin
解决方案:
- 后端配置 CORS(推荐)
- 前端使用代理(Vite 配置)
ts
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
},
},
},
});
🛠️ 进阶功能拓展建议
| 功能 | 实现方式 |
|---|---|
| Markdown 支持 | 使用 marked.js 或 remark |
| 文件上传 | 添加 <input type="file"> 并上传 base64 |
| 多轮对话上下文 | 保存会话 ID 到 localStorage |
| 主题切换 | 使用 Tailwind 的 dark: 类或 CSS 变量 |
| 消息撤回 | 添加"撤销"按钮并删除最后一条消息 |
✅ 最佳实践总结
- 组件解耦 :将逻辑与 UI 分离,使用
composables封装业务逻辑。 - 错误处理全面:网络错误、超时、权限不足都要有反馈。
- 性能优化 :避免重复渲染,合理使用
ref和computed。 - 可访问性 :添加
aria-label、键盘支持。 - 本地存储 :使用
localStorage存储聊天历史,提升用户体验。
📚 结语:AI 助手不只是"炫技",更是"价值"
一个优秀的 AI 聊天助手,不仅是技术的体现,更是对用户需求的深度理解。通过本次实战,我们不仅实现了功能,更掌握了:
- Vue 3 Composition API 的最佳实践
- 状态管理与副作用控制
- 用户体验优化技巧
- 实际开发中的常见陷阱与应对策略
希望这篇教程能帮你快速构建属于自己的 AI 助手,也欢迎你在评论区分享你的改造方案!