<!-- AiChat.vue -->
<template>
<div class="ai-chat">
<!-- 对话历史 -->
<div class="chat-history">
<div v-for="(message, index)" :key="index" class="message">
<div class="user-message" v-if="message.role === 'user'">
<div class="avatar">
<i class="fa fa-user"></i>
</div>
<div class="content">
<div class="text" v-html="message.content"></div>
</div>
</div>
<div class="ai-message" v-else>
<div class="avatar">
<i class="fa fa-robot"></i>
</div>
<div class="content">
<div class="text" v-html="message.displayContent || message.content"></div>
<div v-if="message.loading" class="loading-indicator">
<span>.</span><span>.</span><span>.</span>
</div>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input">
<textarea
v-model="userInput"
placeholder="请输入问题..."
@keyup.enter="sendMessage"
></textarea>
<button @click="sendMessage" :disabled="loading">
{{ loading ? '思考中...' : '发送' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue';
const props = defineProps({
apiUrl: {
type: String,
required: true
},
apiKey: {
type: String,
required: true
}
});
const emits = defineEmits(['messageSent', 'responseReceived', 'error']);
// 对话状态
const messages = reactive([]);
const userInput = ref('');
const loading = ref(false);
// 发送消息
const sendMessage = async () => {
if (!userInput.value.trim() || loading.value) return;
// 添加用户消息
const userMessage = {
role: 'user',
content: userInput.value
};
messages.push(userMessage);
emits('messageSent', userMessage);
// 清空输入框
userInput.value = '';
// 添加AI响应占位
const aiMessage = {
role: 'assistant',
content: '',
displayContent: '',
loading: true
};
messages.push(aiMessage);
try {
loading.value = true;
await streamResponse(aiMessage);
loading.value = false;
} catch (error) {
loading.value = false;
aiMessage.loading = false;
aiMessage.content = '抱歉,出现错误,请重试。';
emits('error', error);
}
};
// 流式接收响应
const streamResponse = async (message) => {
const controller = new AbortController();
const signal = controller.signal;
try {
const response = await fetch(props.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${props.apiKey}`
},
body: JSON.stringify({
prompt: messages.filter(m => m.role === 'user').map(m => m.content).join('\n'),
stream: true
}),
signal
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
message.loading = false;
break;
}
// 解码数据
const chunk = decoder.decode(value, { stream: true });
// 处理SSE格式数据
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
message.loading = false;
continue;
}
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0].delta.content || '';
fullContent += content;
// 更新显示内容
message.content = fullContent;
message.displayContent = formatDisplayContent(fullContent);
emits('responseReceived', {
content,
fullContent
});
} catch (error) {
console.error('解析响应失败:', error);
}
}
}
}
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
} finally {
controller.abort();
}
};
// 格式化显示内容(可添加Markdown解析等)
const formatDisplayContent = (content) => {
// 简单处理换行
return content.replace(/\n/g, '<br>');
};
</script>
<style scoped>
.ai-chat {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.chat-history {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.message {
margin-bottom: 15px;
display: flex;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.user-message .content {
background-color: #e6f7ff;
padding: 10px;
border-radius: 4px;
max-width: 80%;
}
.ai-message .content {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
max-width: 80%;
}
.loading-indicator {
display: flex;
justify-content: center;
margin-top: 5px;
color: #666;
}
.loading-indicator span {
animation: loading 1.4s infinite ease-in-out both;
margin: 0 1px;
}
.loading-indicator span:nth-child(1) { animation-delay: -0.32s; }
.loading-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes loading {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.chat-input {
display: flex;
padding: 10px;
border-top: 1px solid #eee;
}
textarea {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
resize: none;
margin-right: 10px;
}
button {
padding: 8px 15px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background-color: #f0f0f0;
color: #aaa;
cursor: not-allowed;
}
</style>
reactive
存储对话历史ref
管理输入框和加载状态fetch
API发起请求ReadableStream
读取流式数据data: {...}
)displayContent
<template>
<div class="app-container">
<h1>AI聊天助手</h1>
<AiChat
:api-url="apiUrl"
:api-key="apiKey"
@messageSent="handleMessageSent"
@responseReceived="handleResponseReceived"
@error="handleError"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import AiChat from './components/AiChat.vue';
const apiUrl = ref('https://api.openai.com/v1/chat/completions');
const apiKey = ref('your-api-key');
const handleMessageSent = (message) => {
console.log('用户发送消息:', message);
};
const handleResponseReceived = (response) => {
console.log('收到AI响应:', response);
};
const handleError = (error) => {
console.error('发生错误:', error);
};
</script>
<style>
.app-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
// 添加Markdown解析支持
import marked from 'marked';
import DOMPurify from 'dompurify';
// ...原有代码...
// 更新formatDisplayContent函数
const formatDisplayContent = (content) => {
// 使用marked解析Markdown
const html = marked.parse(content);
// 净化HTML防止XSS攻击
const cleanHtml = DOMPurify.sanitize(html);
return cleanHtml;
};
// 添加打字机效果
const typingSpeed = 15; // 毫秒/字符
let typingTimer = null;
// 更新streamResponse函数
const streamResponse = async (message) => {
// ...原有代码...
let fullContent = '';
let displayContent = '';
let lastUpdateTime = 0;
while (true) {
// ...原有代码...
for (const line of lines) {
// ...原有代码...
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0].delta.content || '';
fullContent += content;
// 控制显示速度,实现打字机效果
const now = Date.now();
const timeSinceLastUpdate = now - lastUpdateTime;
if (timeSinceLastUpdate > typingSpeed) {
displayContent += content;
lastUpdateTime = now;
} else {
// 累积内容,稍后显示
displayContent += content;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
message.displayContent = formatDisplayContent(displayContent);
}, typingSpeed);
}
message.content = fullContent;
message.displayContent = formatDisplayContent(displayContent);
emits('responseReceived', {
content,
fullContent
});
} catch (error) {
console.error('解析响应失败:', error);
}
}
}
};
// 增强错误处理
const streamResponse = async (message) => {
try {
// ...原有代码...
if (!response.ok) {
let errorMessage = `HTTP错误! 状态码: ${response.status}`;
try {
const errorData = await response.json();
errorMessage += ` - ${errorData.error.message}`;
} catch (e) {
// 无法解析错误响应
}
throw new Error(errorMessage);
}
// ...原有代码...
} catch (error) {
if (error.name !== 'AbortError') {
// 显示友好的错误消息
message.content = '抱歉,AI回答过程中出现错误。请重试或稍后再试。';
message.displayContent = message.content;
message.loading = false;
// 记录错误日志
console.error('AI响应错误:', error);
emits('error', error);
}
} finally {
controller.abort();
}
};
<!-- 在使用组件时自定义样式 -->
<template>
<div class="app-container">
<h1>AI聊天助手</h1>
<AiChat
:api-url="apiUrl"
:api-key="apiKey"
class="custom-chat"
/>
</div>
</template>
<style scoped>
/* 自定义AI聊天组件样式 */
.custom-chat .user-message .content {
background-color: #dcf8c6;
}
.custom-chat .ai-message .content {
background-color: #ffffff;
border: 1px solid #eee;
}
.custom-chat .avatar {
background-color: #075e54;
color: white;
}
</style>
// 使用vue-virtual-scroller优化长对话
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// 在组件中使用
<RecycleScroller
class="chat-history"
:items="messages"
:item-size="60"
key-field="id"
>
<template #item="{ item, index }">
<!-- 消息内容 -->
</template>
</RecycleScroller>
// 防抖处理用户输入
import { debounce } from 'lodash-es';
const debouncedSendMessage = debounce(sendMessage, 300);
// 在模板中使用
<button @click="debouncedSendMessage" :disabled="loading">发送</button>
// Node.js后端代理示例
const express = require('express');
const axios = require('axios');
const dotenv = require('dotenv');
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
// CORS设置
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
// 代理AI API请求
app.post('/api/chat', async (req, res) => {
try {
const response = await axios.post(
'https://api.openai.com/v1/chat/completions',
req.body,
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
responseType: 'stream'
}
);
// 直接将流式响应转发给客户端
response.data.pipe(res);
} catch (error) {
console.error('代理请求失败:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
通过Vue3的Composition API,我们可以方便地封装一个高效、可复用的AI流式问答组件。关键技术点包括:
这个组件可以轻松集成到各种应用中,如智能客服、聊天机器人、知识问答系统等。根据实际需求,你可以进一步扩展其功能,如添加语音交互、多轮对话上下文管理、知识库集成等。
Vue3,AI 问答组件,AI 流式回答,前端开发,组件封装,人工智能,实时交互,Web 开发,Vue 组件,自然语言处理,前端组件,AI 对话,流式响应,Vue.js, 智能问答
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有