《60天AI学习计划启动 | Day 10: 项目实战 - 完善智能文档助手》

Day 10: 项目实战 - 完善智能文档助手

学习目标


核心学习内容

1. 功能完善

需要完善的功能:

  • 文档管理(增删改查)
  • 文档分类和标签
  • 搜索历史
  • 导出功能
  • 设置面板

2. UI/UX 优化

优化方向:

  • 响应式设计
  • 加载状态优化
  • 错误提示友好
  • 动画效果
  • 快捷键支持

3. 性能优化

优化策略:

  • 缓存机制
  • 懒加载
  • 虚拟滚动
  • 防抖节流
  • 代码分割

实践作业

作业1:完善文档管理功能

src/services/document-manager.js:

import { vectorDB } from './vector-db.js';
import { documentProcessor } from './document-processor.js';
import { logger } from '../utils/logger.js';
import fs from 'fs/promises';
import path from 'path';

/**
 * 文档管理器
 */
export class DocumentManager {
  constructor() {
    this.documents = new Map(); // 文档元数据
    this.storagePath = './data/documents.json';
  }

  /**
   * 添加文档
   */
  async addDocument(text, metadata = {}) {
    try {
      // 处理文档
      const processed = await documentProcessor.processDocument(text, {
        ...metadata,
        createdAt: new Date().toISOString()
      });

      // 保存元数据
      const docMeta = {
        id: processed.documentId,
        title: metadata.title || '未命名文档',
        category: metadata.category || '未分类',
        tags: metadata.tags || [],
        totalChunks: processed.totalChunks,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString()
      };

      this.documents.set(processed.documentId, docMeta);
      await this.saveMetadata();

      return docMeta;
    } catch (error) {
      logger.error('添加文档失败:', error);
      throw error;
    }
  }

  /**
   * 获取文档列表
   */
  getDocuments(filters = {}) {
    let docs = Array.from(this.documents.values());

    // 分类过滤
    if (filters.category) {
      docs = docs.filter(doc => doc.category === filters.category);
    }

    // 标签过滤
    if (filters.tags && filters.tags.length > 0) {
      docs = docs.filter(doc => 
        filters.tags.some(tag => doc.tags.includes(tag))
      );
    }

    // 搜索
    if (filters.search) {
      const searchLower = filters.search.toLowerCase();
      docs = docs.filter(doc => 
        doc.title.toLowerCase().includes(searchLower)
      );
    }

    // 排序
    const sortBy = filters.sortBy || 'createdAt';
    const sortOrder = filters.sortOrder || 'desc';
    docs.sort((a, b) => {
      if (sortOrder === 'asc') {
        return a[sortBy] > b[sortBy] ? 1 : -1;
      } else {
        return a[sortBy] < b[sortBy] ? 1 : -1;
      }
    });

    return docs;
  }

  /**
   * 获取文档详情
   */
  getDocument(id) {
    return this.documents.get(id);
  }

  /**
   * 更新文档
   */
  async updateDocument(id, updates) {
    const doc = this.documents.get(id);
    if (!doc) {
      throw new Error('文档不存在');
    }

    Object.assign(doc, updates, {
      updatedAt: new Date().toISOString()
    });

    await this.saveMetadata();
    return doc;
  }

  /**
   * 删除文档
   */
  async deleteDocument(id) {
    // 从向量数据库删除
    const chunks = await vectorDB.getAllDocuments();
    chunks.forEach(chunk => {
      if (chunk.metadata.documentId === id) {
        vectorDB.deleteDocument(chunk.id);
      }
    });

    // 删除元数据
    this.documents.delete(id);
    await this.saveMetadata();

    return true;
  }

  /**
   * 获取分类列表
   */
  getCategories() {
    const categories = new Set();
    this.documents.forEach(doc => {
      if (doc.category) {
        categories.add(doc.category);
      }
    });
    return Array.from(categories);
  }

  /**
   * 获取标签列表
   */
  getTags() {
    const tags = new Set();
    this.documents.forEach(doc => {
      doc.tags.forEach(tag => tags.add(tag));
    });
    return Array.from(tags);
  }

  /**
   * 保存元数据
   */
  async saveMetadata() {
    try {
      const data = {
        documents: Array.from(this.documents.entries())
      };
      await fs.mkdir(path.dirname(this.storagePath), { recursive: true });
      await fs.writeFile(this.storagePath, JSON.stringify(data, null, 2));
    } catch (error) {
      logger.error('保存元数据失败:', error);
    }
  }

  /**
   * 加载元数据
   */
  async loadMetadata() {
    try {
      const data = await fs.readFile(this.storagePath, 'utf-8');
      const parsed = JSON.parse(data);
      this.documents = new Map(parsed.documents || []);
      logger.info(`加载了 ${this.documents.size} 个文档`);
    } catch (error) {
      if (error.code === 'ENOENT') {
        logger.info('元数据文件不存在,创建新数据库');
        this.documents = new Map();
      } else {
        logger.error('加载元数据失败:', error);
        throw error;
      }
    }
  }
}

export const documentManager = new DocumentManager();

作业2:实现搜索历史

src/services/search-history.js:

/**
 * 搜索历史管理
 */
export class SearchHistory {
  constructor() {
    this.history = [];
    this.maxHistory = 50;
  }

  /**
   * 添加搜索记录
   */
  addSearch(question, answer, sources = []) {
    const record = {
      id: this.generateId(),
      question,
      answer,
      sources,
      timestamp: Date.now(),
      createdAt: new Date().toISOString()
    };

    this.history.unshift(record);

    // 限制历史数量
    if (this.history.length > this.maxHistory) {
      this.history = this.history.slice(0, this.maxHistory);
    }

    return record;
  }

  /**
   * 获取搜索历史
   */
  getHistory(limit = 10) {
    return this.history.slice(0, limit);
  }

  /**
   * 清空历史
   */
  clear() {
    this.history = [];
  }

  /**
   * 删除单条记录
   */
  deleteRecord(id) {
    const index = this.history.findIndex(r => r.id === id);
    if (index !== -1) {
      this.history.splice(index, 1);
      return true;
    }
    return false;
  }

  generateId() {
    return `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
  }
}

export const searchHistory = new SearchHistory();

作业3:优化 RAG 服务

src/services/rag.js(优化版):

// 添加缓存机制
import NodeCache from 'node-cache';

const answerCache = new NodeCache({ stdTTL: 3600 }); // 缓存1小时

export class RAGService {
  // ... 原有代码 ...

  /**
   * 优化后的回答问题(带缓存)
   */
  async answerQuestion(question, options = {}) {
    // 检查缓存
    const cacheKey = this.generateCacheKey(question, options);
    const cached = answerCache.get(cacheKey);
    if (cached) {
      logger.info('使用缓存回答');
      return cached;
    }

    // 生成回答
    const result = await this._answerQuestionInternal(question, options);
    
    // 保存到缓存
    answerCache.set(cacheKey, result);
    
    return result;
  }

  /**
   * 生成缓存键
   */
  generateCacheKey(question, options) {
    return `rag_${question}_${options.topK || 5}`;
  }

  /**
   * 重排序检索结果(提升准确性)
   */
  async rerankResults(question, documents) {
    // 简单的重排序:基于关键词匹配
    const questionWords = question.toLowerCase().split(/\s+/);
    
    return documents.map(doc => {
      const textLower = doc.text.toLowerCase();
      let score = doc.similarity;
      
      // 关键词匹配加分
      questionWords.forEach(word => {
        if (textLower.includes(word)) {
          score += 0.1;
        }
      });
      
      return { ...doc, rerankedScore: score };
    }).sort((a, b) => b.rerankedScore - a.rerankedScore);
  }
}

作业4:完善前端界面

DocumentAssistant.vue(完整版):

<template>
  <div class="document-assistant">
    <!-- 侧边栏 -->
    <div class="sidebar">
      <div class="sidebar-header">
        <h2>📚 文档助手</h2>
        <button @click="showSettings = !showSettings" class="btn-icon">
          ⚙️
        </button>
      </div>

      <!-- 文档管理 -->
      <div class="documents-section">
        <div class="section-header">
          <h3>文档库</h3>
          <div class="actions">
            <button @click="showUpload = true" class="btn-small">+ 上传</button>
            <button @click="showTextInput = !showTextInput" class="btn-small">
              📝
            </button>
          </div>
        </div>

        <!-- 搜索和过滤 -->
        <div class="filters">
          <input
            v-model="searchQuery"
            placeholder="搜索文档..."
            class="search-input" />
          <select v-model="selectedCategory" class="filter-select">
            <option value="">全部分类</option>
            <option v-for="cat in categories" :key="cat" :value="cat">
              {{ cat }}
            </option>
          </select>
        </div>

        <!-- 文档列表 -->
        <div class="documents-list">
          <div
            v-for="doc in filteredDocuments"
            :key="doc.id"
            :class="['doc-item', { active: selectedDoc?.id === doc.id }]"
            @click="selectDocument(doc)">
            <div class="doc-title">{{ doc.title }}</div>
            <div class="doc-meta">
              <span class="doc-category">{{ doc.category }}</span>
              <span class="doc-chunks">{{ doc.totalChunks }} 块</span>
            </div>
            <div class="doc-tags">
              <span v-for="tag in doc.tags" :key="tag" class="tag">
                {{ tag }}
              </span>
            </div>
          </div>
        </div>
      </div>

      <!-- 搜索历史 -->
      <div class="history-section">
        <h3>搜索历史</h3>
        <div class="history-list">
          <div
            v-for="record in searchHistory"
            :key="record.id"
            class="history-item"
            @click="loadHistory(record)">
            <div class="history-question">{{ record.question }}</div>
            <div class="history-time">{{ formatTime(record.timestamp) }}</div>
          </div>
        </div>
      </div>
    </div>

    <!-- 主内容区 -->
    <div class="main-content">
      <!-- 问答区域 -->
      <div class="qa-section">
        <div class="question-input-wrapper">
          <textarea
            v-model="question"
            @keydown="handleKeyDown"
            placeholder="基于文档内容提问..."
            class="question-input"
            rows="2" />
          <button
            @click="askQuestion"
            :disabled="loading || !question.trim()"
            class="btn-ask">
            {{ loading ? '回答中...' : '提问' }}
          </button>
        </div>

        <!-- 回答区域 -->
        <div v-if="currentAnswer" class="answer-wrapper">
          <div class="answer-header">
            <span>AI 回答</span>
            <div class="answer-actions">
              <button @click="copyAnswer" class="btn-icon-small">📋</button>
              <button @click="exportAnswer" class="btn-icon-small">💾</button>
            </div>
          </div>
          <div class="answer-content" v-html="formatAnswer(currentAnswer)"></div>

          <!-- 来源文档 -->
          <div v-if="currentSources.length > 0" class="sources-section">
            <h4>参考来源 ({{ currentSources.length }})</h4>
            <div
              v-for="(source, index) in currentSources"
              :key="index"
              class="source-card">
              <div class="source-text">{{ source.text }}</div>
              <div class="source-meta">
                <span class="similarity">
                  相似度: {{ (source.similarity * 100).toFixed(1) }}%
                </span>
                <span v-if="source.metadata.category" class="category">
                  {{ source.metadata.category }}
                </span>
              </div>
            </div>
          </div>
        </div>

        <!-- 空状态 -->
        <div v-else class="empty-state">
          <div class="empty-icon">💬</div>
          <h3>开始提问</h3>
          <p>基于已上传的文档内容提问,AI 会基于文档回答</p>
          <div class="quick-questions">
            <button
              v-for="q in quickQuestions"
              :key="q"
              @click="question = q; askQuestion()"
              class="quick-btn">
              {{ q }}
            </button>
          </div>
        </div>
      </div>
    </div>

    <!-- 上传弹窗 -->
    <div v-if="showUpload" class="modal" @click.self="showUpload = false">
      <div class="modal-content">
        <h3>上传文档</h3>
        <input
          type="file"
          ref="fileInput"
          @change="handleFileUpload"
          accept=".txt,.md"
          style="display: none" />
        <button @click="$refs.fileInput.click()" class="btn-upload">
          选择文件
        </button>
        <div class="text-input-area">
          <textarea
            v-model="textInput"
            placeholder="或直接粘贴文本内容..."
            rows="8" />
          <button @click="addText" :disabled="!textInput.trim()">
            添加文本
          </button>
        </div>
        <button @click="showUpload = false" class="btn-close">关闭</button>
      </div>
    </div>
  </div>
</template>

<script>
import { streamChatWithRAG } from '@/utils/api';
import { debounce } from 'lodash-es';

export default {
  name: 'DocumentAssistant',
  data() {
    return {
      documents: [],
      filteredDocuments: [],
      selectedDoc: null,
      question: '',
      currentAnswer: '',
      currentSources: [],
      loading: false,
      showUpload: false,
      showTextInput: false,
      showSettings: false,
      textInput: '',
      searchQuery: '',
      selectedCategory: '',
      categories: [],
      searchHistory: [],
      quickQuestions: [
        '文档的主要内容是什么?',
        '有哪些关键概念?',
        '总结一下文档要点'
      ]
    };
  },
  mounted() {
    this.loadDocuments();
    this.loadSearchHistory();
    this.debouncedSearch = debounce(this.filterDocuments, 300);
  },
  watch: {
    searchQuery() {
      this.debouncedSearch();
    },
    selectedCategory() {
      this.filterDocuments();
    }
  },
  methods: {
    async loadDocuments() {
      try {
        const response = await fetch('http://localhost:3000/api/documents');
        const result = await response.json();
        if (result.success) {
          this.documents = result.data;
          this.filteredDocuments = this.documents;
          this.categories = this.getCategories();
        }
      } catch (error) {
        console.error('加载文档失败:', error);
      }
    },

    filterDocuments() {
      let filtered = this.documents;

      if (this.searchQuery) {
        const query = this.searchQuery.toLowerCase();
        filtered = filtered.filter(doc =>
          doc.title.toLowerCase().includes(query)
        );
      }

      if (this.selectedCategory) {
        filtered = filtered.filter(doc =>
          doc.category === this.selectedCategory
        );
      }

      this.filteredDocuments = filtered;
    },

    getCategories() {
      const cats = new Set();
      this.documents.forEach(doc => {
        if (doc.category) cats.add(doc.category);
      });
      return Array.from(cats);
    },

    async askQuestion() {
      if (!this.question.trim() || this.loading) return;

      this.loading = true;
      this.currentAnswer = '';
      this.currentSources = [];

      try {
        await streamChatWithRAG(this.question, {
          onChunk: (content) => {
            this.currentAnswer += content;
          },
          onComplete: (result) => {
            this.currentSources = result.sources || [];
            this.loading = false;
            this.saveSearchHistory();
          },
          onError: (error) => {
            this.currentAnswer = error.message || '回答失败';
            this.loading = false;
          }
        });
      } catch (error) {
        this.currentAnswer = error.message || '回答失败';
        this.loading = false;
      }
    },

    saveSearchHistory() {
      // 保存到后端或本地存储
      const record = {
        question: this.question,
        answer: this.currentAnswer,
        sources: this.currentSources,
        timestamp: Date.now()
      };
      this.searchHistory.unshift(record);
      if (this.searchHistory.length > 20) {
        this.searchHistory = this.searchHistory.slice(0, 20);
      }
      localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
    },

    loadSearchHistory() {
      const saved = localStorage.getItem('searchHistory');
      if (saved) {
        this.searchHistory = JSON.parse(saved);
      }
    },

    handleKeyDown(event) {
      if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        this.askQuestion();
      }
    },

    formatAnswer(text) {
      return text
        .replace(/\n/g, '<br>')
        .replace(/`([^`]+)`/g, '<code>$1</code>')
        .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
    },

    formatTime(timestamp) {
      const date = new Date(timestamp);
      return date.toLocaleString('zh-CN');
    },

    copyAnswer() {
      navigator.clipboard.writeText(this.currentAnswer);
      this.$message.success('已复制到剪贴板');
    }
  }
};
</script>

<style scoped>
/* 样式代码较长,这里省略,实际项目中需要完整实现 */
.document-assistant {
  display: flex;
  height: 100vh;
}

.sidebar {
  width: 300px;
  border-right: 1px solid #eee;
  overflow-y: auto;
}

.main-content {
  flex: 1;
  display: flex;
  flex-direction: column;
}
/* ... 更多样式 ... */
</style>

作业5:性能优化

优化点:

// 1. 防抖搜索
const debouncedSearch = debounce(this.filterDocuments, 300);

// 2. 虚拟滚动(长列表)
import { RecycleScroller } from 'vue-virtual-scroller';

// 3. 懒加载文档
const loadDocuments = async () => {
  if (this.loading) return;
  this.loading = true;
  // 分页加载
  const page = Math.floor(this.documents.length / 20);
  // ...
};

// 4. 缓存 API 响应
const cache = new Map();
if (cache.has(key)) {
  return cache.get(key);
}

作业6:错误处理完善

src/middleware/error-handler.js:

export function errorHandler(err, req, res, next) {
  logger.error('错误:', err);

  // 根据错误类型返回不同响应
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      success: false,
      error: '参数验证失败',
      details: err.message
    });
  }

  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({
      success: false,
      error: '未授权'
    });
  }

  // 默认错误
  res.status(500).json({
    success: false,
    error: process.env.NODE_ENV === 'production' 
      ? '服务器错误' 
      : err.message
  });
}

遇到的问题

问题1:大量文档性能问题

解决方案:

  • 分页加载
  • 虚拟滚动
  • 索引优化

问题2:搜索响应慢

解决方案:

  • 防抖处理
  • 缓存结果
  • 异步搜索

学习总结

今日收获

  1. ✅ 完善文档管理功能
  2. ✅ 优化 UI/UX
  3. ✅ 实现性能优化
  4. ✅ 完善错误处理
  5. ✅ 项目功能完整

关键知识点

  • 功能完善,让项目更实用
  • UI/UX 优化,提升用户体验
  • 性能优化,提高响应速度
  • 错误处理,增强稳定性

明日计划

明天将学习:

期待明天的学习! 🚀


参考资源


代码仓库

项目已完成:

  • ✅ 完整文档助手
  • ✅ 功能完善
  • ✅ 性能优化
  • ✅ 准备部署

GitHub 提交: Day 10 - 项目完善


标签: #AI学习 #项目实战 #性能优化 #UI/UX #学习笔记


写在最后

今天完成了智能文档助手的完善工作,项目功能已经完整。
这是一个重要的里程碑,整合了前面9天学到的所有知识。
明天将学习 LangChain 框架,进一步提升开发效率!

继续加油! 💪


快速检查清单

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

posted @ 2025-12-16 15:42  XiaoZhengTou  阅读(1)  评论(0)    收藏  举报