《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:搜索响应慢
解决方案:
- 防抖处理
- 缓存结果
- 异步搜索
学习总结
今日收获
- ✅ 完善文档管理功能
- ✅ 优化 UI/UX
- ✅ 实现性能优化
- ✅ 完善错误处理
- ✅ 项目功能完整
关键知识点
- 功能完善,让项目更实用
- UI/UX 优化,提升用户体验
- 性能优化,提高响应速度
- 错误处理,增强稳定性
明日计划
明天将学习:
期待明天的学习! 🚀
参考资源
代码仓库
项目已完成:
- ✅ 完整文档助手
- ✅ 功能完善
- ✅ 性能优化
- ✅ 准备部署
GitHub 提交: Day 10 - 项目完善
标签: #AI学习 #项目实战 #性能优化 #UI/UX #学习笔记
写在最后
今天完成了智能文档助手的完善工作,项目功能已经完整。
这是一个重要的里程碑,整合了前面9天学到的所有知识。
明天将学习 LangChain 框架,进一步提升开发效率!
继续加油! 💪
快速检查清单
完成这些,第十天就达标了! ✅

浙公网安备 33010602011771号