《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';
}

📊 学习总结

今日收获

  1. ✅ 整合前4天知识点
  2. ✅ 构建完整聊天机器人
  3. ✅ 实现对话历史管理
  4. ✅ 优化 UI/UX
  5. ✅ 添加实用功能

关键知识点

  • 项目整合很重要,将知识点串联起来
  • 对话历史管理,保持上下文连贯
  • UI/UX 优化,提升用户体验
  • 功能完善,让项目更实用

项目亮点

  • ✅ 流式响应,体验流畅
  • ✅ 对话历史,上下文连贯
  • ✅ 快捷问题,提升效率
  • ✅ 设置面板,个性化配置
  • ✅ 响应式设计,适配多端

📅 明日计划

明天将学习:

期待明天的学习! 🚀


📚 参考资源


💻 代码仓库

项目已完成:

  • ✅ 完整聊天机器人
  • ✅ 对话历史管理
  • ✅ UI/UX 优化
  • ✅ 实用功能

GitHub 提交: Day 05 - 项目实战:简单聊天机器人


💭 写在最后

今天完成了第一个完整的 AI 项目!虽然功能简单,但已经整合了所有核心知识点。这是一个重要的里程碑,为后续学习打下了坚实基础。明天将学习提示工程,让 AI 回答更精准!

继续加油! 💪


✅ 快速检查清单

完成这些,第五天就达标了!


标签: #AI学习 #项目实战 #聊天机器人 #Vue #学习笔记

posted @ 2025-12-16 14:36  XiaoZhengTou  阅读(4)  评论(0)    收藏  举报