《60天AI学习计划启动 | Day 05: 项目实战 - 简单聊天机器人》
Day 05: 项目实战 - 简单聊天机器人
📋 学习目标
📚 核心学习内容
1. 项目架构设计
功能模块:
聊天机器人项目
├── 前端 (Vue/React)
│ ├── 消息列表
│ ├── 输入框
│ ├── 对话历史
│ └── 设置面板
├── 后端 (Express)
│ ├── API 路由
│ ├── OpenAI 服务
│ ├── 对话管理
│ └── 错误处理
└── 数据存储
├── 对话历史(内存/数据库)
└── 用户设置
2. 对话历史管理
需求:
- 保存对话上下文
- 支持多轮对话
- 限制历史长度(避免 Token 超限)
- 支持清空历史
3. UI/UX 优化
关键点:
- 消息气泡样式
- 加载状态显示
- 错误提示友好
- 响应式设计
- 快捷键支持
🏗️ 实践作业
作业1:完善后端对话管理
src/services/conversation.js:
// 对话历史管理服务
class ConversationManager {
constructor() {
// 内存存储(生产环境建议用数据库)
this.conversations = new Map();
this.maxHistoryLength = 20; // 最多保存20轮对话
}
/**
* 获取对话历史
*/
getHistory(conversationId) {
if (!conversationId) {
return [];
}
const history = this.conversations.get(conversationId);
return history || [];
}
/**
* 添加消息到历史
*/
addMessage(conversationId, role, content) {
if (!conversationId) {
conversationId = this.createConversationId();
}
if (!this.conversations.has(conversationId)) {
this.conversations.set(conversationId, []);
}
const history = this.conversations.get(conversationId);
history.push({ role, content, timestamp: Date.now() });
// 限制历史长度
if (history.length > this.maxHistoryLength) {
history.shift(); // 移除最旧的消息
}
return conversationId;
}
/**
* 清空对话历史
*/
clearHistory(conversationId) {
if (conversationId) {
this.conversations.delete(conversationId);
} else {
this.conversations.clear();
}
}
/**
* 创建对话ID
*/
createConversationId() {
return `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 转换为 OpenAI 格式
*/
toOpenAIFormat(conversationId) {
const history = this.getHistory(conversationId);
return history.map(msg => ({
role: msg.role,
content: msg.content
}));
}
}
export const conversationManager = new ConversationManager();
作业2:更新聊天路由
src/routes/chat.js(完整版):
import express from 'express';
import { chatWithAI, streamChatWithAI } from '../services/openai.js';
import { conversationManager } from '../services/conversation.js';
import { logger } from '../utils/logger.js';
export const chatRouter = express.Router();
// POST /api/chat - 普通聊天
chatRouter.post('/', async (req, res) => {
try {
const { message, conversationId } = req.body;
if (!message || typeof message !== 'string') {
return res.status(400).json({
success: false,
error: '消息内容不能为空'
});
}
// 获取或创建对话ID
let currentConversationId = conversationId ||
conversationManager.createConversationId();
// 获取对话历史
const history = conversationManager.toOpenAIFormat(currentConversationId);
// 调用 AI
const response = await chatWithAI(message, history);
// 保存对话历史
conversationManager.addMessage(
currentConversationId,
'user',
message
);
conversationManager.addMessage(
currentConversationId,
'assistant',
response.content
);
res.json({
success: true,
data: {
message: response.content,
usage: response.usage,
conversationId: currentConversationId
}
});
} catch (error) {
logger.error('聊天接口错误:', error);
res.status(500).json({
success: false,
error: error.message || 'AI服务暂时不可用'
});
}
});
// POST /api/chat/stream - 流式聊天
chatRouter.post('/stream', async (req, res) => {
try {
const { message, conversationId } = req.body;
if (!message || typeof message !== 'string') {
return res.status(400).json({
success: false,
error: '消息内容不能为空'
});
}
// 获取或创建对话ID
let currentConversationId = conversationId ||
conversationManager.createConversationId();
// 获取对话历史
const history = conversationManager.toOpenAIFormat(currentConversationId);
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
let fullContent = '';
// 调用流式 AI 服务
await streamChatWithAI(message, history, {
onChunk: (content) => {
fullContent += content;
res.write(`data: ${JSON.stringify({
content,
conversationId: currentConversationId
})}\n\n`);
},
onComplete: (usage) => {
// 保存对话历史
conversationManager.addMessage(
currentConversationId,
'user',
message
);
conversationManager.addMessage(
currentConversationId,
'assistant',
fullContent
);
res.write(`data: ${JSON.stringify({
done: true,
usage,
conversationId: currentConversationId
})}\n\n`);
res.end();
},
onError: (error) => {
res.write(`data: ${JSON.stringify({
error: error.message
})}\n\n`);
res.end();
}
});
} catch (error) {
logger.error('流式接口错误:', error);
if (!res.headersSent) {
res.status(500).json({
success: false,
error: error.message || '流式服务异常'
});
}
}
});
// DELETE /api/chat/history - 清空对话历史
chatRouter.delete('/history', (req, res) => {
const { conversationId } = req.body;
conversationManager.clearHistory(conversationId);
res.json({
success: true,
message: '对话历史已清空'
});
});
// GET /api/chat/history - 获取对话历史
chatRouter.get('/history/:conversationId', (req, res) => {
const { conversationId } = req.params;
const history = conversationManager.getHistory(conversationId);
res.json({
success: true,
data: history
});
});
作业3:完善前端聊天组件
ChatBot.vue(完整版):
<template>
<div class="chatbot-container">
<!-- 头部 -->
<div class="chat-header">
<h2>AI 聊天助手</h2>
<div class="header-actions">
<button
@click="clearHistory"
class="btn-icon"
title="清空对话">
<span>🗑️</span>
</button>
<button
@click="toggleSettings"
class="btn-icon"
title="设置">
<span>⚙️</span>
</button>
</div>
</div>
<!-- 消息列表 -->
<div class="messages-container" ref="messagesContainer">
<!-- 欢迎消息 -->
<div v-if="messages.length === 0" class="welcome-message">
<div class="welcome-icon">🤖</div>
<h3>你好,我是 AI 助手</h3>
<p>有什么问题可以问我哦~</p>
<div class="quick-questions">
<button
v-for="(q, index) in quickQuestions"
:key="index"
@click="askQuickQuestion(q)"
class="quick-btn">
{{ q }}
</button>
</div>
</div>
<!-- 消息列表 -->
<div
v-for="(msg, index) in messages"
:key="index"
:class="['message', msg.type]">
<div class="message-avatar">
<span v-if="msg.type === 'user'">👤</span>
<span v-else>🤖</span>
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(msg.content)"></div>
<div class="message-meta">
<span class="message-time">{{ formatTime(msg.timestamp) }}</span>
<span v-if="msg.usage" class="message-usage">
Token: {{ msg.usage.total_tokens }}
</span>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading && !currentStreaming" class="message ai loading">
<div class="message-avatar">🤖</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-container">
<div class="input-wrapper">
<textarea
ref="inputRef"
v-model="inputMessage"
@keydown="handleKeyDown"
@input="handleInput"
placeholder="输入消息(Enter发送,Shift+Enter换行)..."
:disabled="loading"
rows="1"
class="message-input" />
<button
@click="sendMessage"
:disabled="loading || !inputMessage.trim()"
class="send-btn">
<span v-if="loading">⏳</span>
<span v-else>📤</span>
</button>
</div>
<div class="input-hint">
<span>💡 提示:输入 /help 查看帮助</span>
</div>
</div>
<!-- 设置面板 -->
<div v-if="showSettings" class="settings-panel">
<h3>设置</h3>
<div class="setting-item">
<label>模型:</label>
<select v-model="settings.model">
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option>
<option value="gpt-4">GPT-4</option>
</select>
</div>
<div class="setting-item">
<label>Temperature:</label>
<input
type="range"
v-model.number="settings.temperature"
min="0"
max="2"
step="0.1" />
<span>{{ settings.temperature }}</span>
</div>
<button @click="saveSettings" class="btn-primary">保存</button>
</div>
</div>
</template>
<script>
import { streamChatWithAI } from '@/utils/api';
export default {
name: 'ChatBot',
data() {
return {
messages: [],
inputMessage: '',
loading: false,
conversationId: null,
currentStreaming: false,
showSettings: false,
quickQuestions: [
'介绍一下你自己',
'前端开发最佳实践',
'如何学习 AI?'
],
settings: {
model: 'gpt-3.5-turbo',
temperature: 0.7
}
};
},
mounted() {
// 加载设置
this.loadSettings();
// 聚焦输入框
this.$nextTick(() => {
this.$refs.inputRef?.focus();
});
},
methods: {
async sendMessage() {
if (!this.inputMessage.trim() || this.loading) return;
// 处理特殊命令
if (this.inputMessage.trim() === '/help') {
this.showHelp();
this.inputMessage = '';
return;
}
if (this.inputMessage.trim() === '/clear') {
this.clearHistory();
this.inputMessage = '';
return;
}
const userMessage = this.inputMessage.trim();
this.inputMessage = '';
// 添加用户消息
this.messages.push({
type: 'user',
content: userMessage,
timestamp: Date.now()
});
// 创建AI消息占位
const aiMessageIndex = this.messages.length;
this.messages.push({
type: 'ai',
content: '',
streaming: true,
timestamp: Date.now()
});
this.loading = true;
this.currentStreaming = true;
try {
await streamChatWithAI(
userMessage,
this.getConversationHistory(),
{
onChunk: (content) => {
this.$set(this.messages[aiMessageIndex], 'content',
this.messages[aiMessageIndex].content + content
);
this.scrollToBottom();
},
onComplete: (usage) => {
this.$set(this.messages[aiMessageIndex], 'streaming', false);
this.$set(this.messages[aiMessageIndex], 'usage', usage);
this.loading = false;
this.currentStreaming = false;
this.scrollToBottom();
},
onError: (error) => {
this.messages[aiMessageIndex].content =
error.message || '请求失败,请稍后重试';
this.messages[aiMessageIndex].streaming = false;
this.loading = false;
this.currentStreaming = false;
}
},
this.conversationId
);
} catch (error) {
this.messages[aiMessageIndex].content =
error.message || '请求失败,请稍后重试';
this.messages[aiMessageIndex].streaming = false;
this.loading = false;
this.currentStreaming = false;
}
},
getConversationHistory() {
return this.messages
.filter(msg => msg.type !== 'user' || msg.content)
.map(msg => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content
}))
.slice(-10); // 只保留最近10轮对话
},
askQuickQuestion(question) {
this.inputMessage = question;
this.sendMessage();
},
clearHistory() {
if (confirm('确定要清空所有对话吗?')) {
this.messages = [];
this.conversationId = null;
this.$message.success('对话已清空');
}
},
toggleSettings() {
this.showSettings = !this.showSettings;
},
saveSettings() {
localStorage.setItem('chatbot-settings', JSON.stringify(this.settings));
this.$message.success('设置已保存');
this.showSettings = false;
},
loadSettings() {
const saved = localStorage.getItem('chatbot-settings');
if (saved) {
this.settings = JSON.parse(saved);
}
},
showHelp() {
this.messages.push({
type: 'ai',
content: `可用命令:
/help - 显示帮助
/clear - 清空对话
快捷键:
Enter - 发送消息
Shift+Enter - 换行`,
timestamp: Date.now()
});
},
handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
}
},
handleInput(event) {
// 自动调整高度
const textarea = event.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
},
formatMessage(content) {
// 简单的 Markdown 渲染
return content
.replace(/\n/g, '<br>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
},
formatTime(timestamp) {
const date = new Date(timestamp);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
},
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
}
};
</script>
<style scoped>
.chatbot-container {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 900px;
margin: 0 auto;
background: #fff;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
}
.chat-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
background: #f8f9fa;
}
.chat-header h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.header-actions {
display: flex;
gap: 8px;
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.btn-icon:hover {
background: #e9ecef;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f5f5f5;
}
.welcome-message {
text-align: center;
padding: 60px 20px;
}
.welcome-icon {
font-size: 64px;
margin-bottom: 20px;
}
.quick-questions {
margin-top: 30px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.quick-btn {
padding: 12px 24px;
border: 1px solid #ddd;
background: white;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
max-width: 400px;
width: 100%;
}
.quick-btn:hover {
background: #e3f2fd;
border-color: #1976d2;
}
.message {
display: flex;
gap: 12px;
margin-bottom: 20px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
background: #e3f2fd;
}
.message.user .message-avatar {
background: #f3e5f5;
}
.message-content {
max-width: 70%;
background: white;
padding: 12px 16px;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.message.user .message-content {
background: #1976d2;
color: white;
}
.message-text {
word-break: break-word;
line-height: 1.6;
}
.message-text code {
background: rgba(0,0,0,0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.message-meta {
margin-top: 8px;
font-size: 12px;
opacity: 0.7;
display: flex;
gap: 12px;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 0;
}
.typing-indicator span {
width: 8px;
height: 8px;
border-radius: 50%;
background: #999;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
}
.input-container {
padding: 16px 20px;
border-top: 1px solid #eee;
background: white;
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message-input {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
resize: none;
font-size: 14px;
font-family: inherit;
max-height: 150px;
overflow-y: auto;
}
.message-input:focus {
outline: none;
border-color: #1976d2;
}
.send-btn {
padding: 12px 20px;
background: #1976d2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.send-btn:hover:not(:disabled) {
background: #1565c0;
}
.send-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.input-hint {
margin-top: 8px;
font-size: 12px;
color: #999;
text-align: center;
}
.settings-panel {
position: absolute;
top: 60px;
right: 20px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 300px;
z-index: 100;
}
.setting-item {
margin-bottom: 16px;
}
.setting-item label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.setting-item input[type="range"] {
width: 100%;
}
.btn-primary {
width: 100%;
padding: 10px;
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
作业4:更新 API 工具
utils/api.js(添加对话ID支持):
export function streamChatWithAI(
message,
conversationHistory = [],
callbacks = {},
conversationId = null
) {
const { onChunk, onComplete, onError } = callbacks;
return new Promise((resolve, reject) => {
fetch('http://localhost:3000/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationHistory,
conversationId
})
})
.then(response => {
if (!response.ok) {
throw new Error('请求失败');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentConversationId = conversationId;
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
if (onComplete) {
onComplete();
}
resolve({ conversationId: currentConversationId });
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.error) {
if (onError) {
onError(new Error(data.error));
}
reject(new Error(data.error));
return;
}
if (data.done) {
if (onComplete) {
onComplete(data.usage);
}
resolve({
conversationId: data.conversationId || currentConversationId
});
return;
}
if (data.conversationId) {
currentConversationId = data.conversationId;
}
if (data.content && onChunk) {
onChunk(data.content);
}
} catch (e) {
console.error('解析数据失败:', e);
}
}
}
readStream();
}).catch(error => {
if (onError) {
onError(error);
}
reject(error);
});
}
readStream();
})
.catch(error => {
if (onError) {
onError(error);
}
reject(error);
});
});
}
⚠️ 遇到的问题
问题1:对话历史过长导致 Token 超限
解决方案:
// 限制历史长度
getConversationHistory() {
return this.messages
.slice(-10) // 只保留最近10轮
.map(msg => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content
}));
}
问题2:消息滚动不流畅
解决方案:
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
});
}
问题3:输入框高度自适应
解决方案:
handleInput(event) {
const textarea = event.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px';
}
📊 学习总结
今日收获
- ✅ 整合前4天知识点
- ✅ 构建完整聊天机器人
- ✅ 实现对话历史管理
- ✅ 优化 UI/UX
- ✅ 添加实用功能
关键知识点
- 项目整合很重要,将知识点串联起来
- 对话历史管理,保持上下文连贯
- UI/UX 优化,提升用户体验
- 功能完善,让项目更实用
项目亮点
- ✅ 流式响应,体验流畅
- ✅ 对话历史,上下文连贯
- ✅ 快捷问题,提升效率
- ✅ 设置面板,个性化配置
- ✅ 响应式设计,适配多端
📅 明日计划
明天将学习:
期待明天的学习! 🚀
📚 参考资源
💻 代码仓库
项目已完成:
- ✅ 完整聊天机器人
- ✅ 对话历史管理
- ✅ UI/UX 优化
- ✅ 实用功能
GitHub 提交: Day 05 - 项目实战:简单聊天机器人
💭 写在最后
今天完成了第一个完整的 AI 项目!虽然功能简单,但已经整合了所有核心知识点。这是一个重要的里程碑,为后续学习打下了坚实基础。明天将学习提示工程,让 AI 回答更精准!
继续加油! 💪
✅ 快速检查清单
完成这些,第五天就达标了! ✅
标签: #AI学习 #项目实战 #聊天机器人 #Vue #学习笔记

浙公网安备 33010602011771号