【SpringAI实战】ChatPDF实现RAG知识库
一、前言
二、实现效果
三、代码实现
3.1 后端代码
3.2 前端代码
一、前言
Spring AI详解:【Spring AI详解】开启Java生态的智能应用开发新时代(附不同功能的Spring AI实战项目)-CSDN博客
二、实现效果
实现一个非常火爆的个人知识库AI应用,ChatPDF,原网站如下:
这个网站其实就是把你个人的PDF文件作为知识库,让AI基于PDF内容来回答你的问题,对于大学生、研究人员、专业人士来说,非常方便。
我们下面代码的实现效果:
三、代码实现
3.1 后端代码
pom.xml
org.springframework.boot spring-boot-starter-parent 3.4.3 17 1.0.0-M6 org.springframework.boot spring-boot-starter-web org.springframework.ai spring-ai-ollama-spring-boot-starter org.springframework.ai spring-ai-openai-spring-boot-starter org.springframework.ai spring-ai-pdf-document-reader org.projectlombok lombok 1.18.22 provided org.springframework.ai spring-ai-bom ${spring-ai.version} pom import
application.ymal
可选择ollama或者openai其一进行大模型配置
server: tomcat: max-swallow-size: -1 # 禁用Tomcat的请求大小限制(或设为足够大的值,如100MB)spring: application: name: spring-ai-dome # 应用名称(用于服务发现和监控) servlet: multipart: max-file-size: 50MB # 单个文件限制 max-request-size: 100MB # 单次请求总限制 # AI服务配置(多引擎支持) ai: # Ollama配置(本地大模型引擎) ollama: base-url: http://localhost:11434 # Ollama服务地址(默认端口11434) chat: model: deepseek-r1:7b # 使用的模型名称(7B参数的本地模型) # 阿里云OpenAI兼容模式配置 openai: base-url: https://dashscope.aliyuncs.com/compatible-mode # 阿里云兼容API端点 api-key: ${OPENAI_API_KEY} # 从环境变量读取API密钥(安全建议) chat: options: model: qwen-max-latest # 通义千问最新版本模型 embedding: options: model: text-embedding-v3 dimensions: 1024# 日志级别配置logging: level: org.springframework.ai: debug # 打印Spring AI框架调试日志 com.itheima.ai: debug # 打印业务代码调试日志
CommonConfiguration 配置类
import io.micrometer.observation.ObservationRegistry;import org.springframework.ai.autoconfigure.openai.OpenAiChatProperties;import org.springframework.ai.autoconfigure.openai.OpenAiConnectionProperties;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;import org.springframework.ai.chat.memory.ChatMemory;import org.springframework.ai.chat.memory.InMemoryChatMemory;import org.springframework.ai.chat.observation.ChatModelObservationConvention;import org.springframework.ai.chat.prompt.ChatOptions;import org.springframework.ai.model.SimpleApiKey;import org.springframework.ai.model.tool.ToolCallingManager;import org.springframework.ai.ollama.OllamaChatModel;import org.springframework.ai.openai.OpenAiChatModel;import org.springframework.ai.openai.OpenAiEmbeddingModel;import org.springframework.ai.openai.api.OpenAiApi;import org.springframework.ai.vectorstore.SearchRequest;import org.springframework.ai.vectorstore.SimpleVectorStore;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.beans.factory.ObjectProvider;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.retry.support.RetryTemplate;import org.springframework.util.CollectionUtils;import org.springframework.util.StringUtils;import org.springframework.web.client.ResponseErrorHandler;import org.springframework.web.client.RestClient;import org.springframework.web.reactive.function.client.WebClient;import java.util.*;/** * AI核心配置类 * * 核心组件: * 聊天记忆管理(ChatMemory) * 向量存储(VectorStore) */@Configurationpublic class CommonConfiguration { /** * 内存式聊天记忆存储 * @return InMemoryChatMemory 实例 * * 作用:保存对话上下文,实现多轮对话能力 * 实现原理:基于ConcurrentHashMap的线程安全实现 */ @Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } /** * 向量存储配置 * @param embeddingModel 嵌入模型(用于文本向量化) * @return SimpleVectorStore 实例 * * 应用场景: * - 文档语义搜索 * - PDF内容检索 */ @Bean public VectorStore vectorStore(OpenAiEmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); } /** * PDF文档问答客户端 * @param model OpenAI模型 * @param chatMemory 聊天记忆 * @param vectorStore 向量存储 * @return PDF专用ChatClient * * 核心机制: * - 基于向量相似度检索(相似度阈值0.6,返回Top2结果) */ @Bean public ChatClient pdfChatClient(OpenAiChatModel model, ChatMemory chatMemory, VectorStore vectorStore) { return ChatClient .builder(model) .defaultSystem(\"请根据上下文回答问题,遇到上下文没有的问题,不要随意编造。\") .defaultAdvisors( new SimpleLoggerAdvisor(), new MessageChatMemoryAdvisor(chatMemory), new QuestionAnswerAdvisor( // 向量检索增强 vectorStore, SearchRequest.builder() .similarityThreshold(0.6) // 相似度阈值 .topK(2) // 返回结果数 .build() ) ) .build(); }}
ChatHistoryRepository 会话历史业务接口
import java.util.List;public interface ChatHistoryRepository { /** * 保存会话记录 * @param type 业务类型,如:chat、service、pdf * @param chatId 会话ID */ void save(String type, String chatId); /** * 获取会话ID列表 * @param type 业务类型,如:chat、service、pdf * @return 会话ID列表 */ List getChatIds(String type);}
InMemoryChatHistoryRepository 会话历史实现类
@Slf4j@Component@RequiredArgsConstructorpublic class InMemoryChatHistoryRepository implements ChatHistoryRepository { // 会话chatId存储Map private Map<String, List> chatHistory; private final ChatMemory chatMemory; // 保存会话ID @Override public void save(String type, String chatId) { /*if (!chatHistory.containsKey(type)) { chatHistory.put(type, new ArrayList()); } List chatIds = chatHistory.get(type);*/ List chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList()); if (chatIds.contains(chatId)) { return; } chatIds.add(chatId); } // 获取所有会话id @Override public List getChatIds(String type) { /*List chatIds = chatHistory.get(type); return chatIds == null ? List.of() : chatIds;*/ return chatHistory.getOrDefault(type, List.of()); }}
FileRepository PDF文件业务接口
import org.springframework.core.io.Resource;public interface FileRepository { /** * 保存文件,还要记录chatId与文件的映射关系 * @param chatId 会话id * @param resource 文件 * @return 上传成功,返回true; 否则返回false */ boolean save(String chatId, Resource resource); /** * 根据chatId获取文件 * @param chatId 会话id * @return 找到的文件 */ Resource getFile(String chatId);}
LocalPdfFileRepository 文件存储实现类
import jakarta.annotation.PostConstruct;import jakarta.annotation.PreDestroy;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.ai.vectorstore.SimpleVectorStore;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.core.io.FileSystemResource;import org.springframework.core.io.Resource;import org.springframework.stereotype.Component;import org.springframework.web.multipart.MultipartFile;import java.io.*;import java.nio.charset.StandardCharsets;import java.nio.file.Files;import java.time.LocalDateTime;import java.util.Objects;import java.util.Properties;/** * 本地PDF文件存储仓库实现类 * 功能: * 1. PDF文件的本地存储管理 * 2. 会话与文件的映射关系维护 * 3. 向量存储的持久化与恢复 * * 设计特点: * - 使用Properties文件维护会话ID与文件名的映射 * - 实现VectorStore的自动加载/保存 * - 支持文件资源的本地存储 */@Slf4j@Component@RequiredArgsConstructorpublic class LocalPdfFileRepository implements FileRepository { // 向量存储接口(实际使用SimpleVectorStore实现) private final VectorStore vectorStore; // 维护会话ID与PDF文件名的映射关系 // Key: 会话ID, Value: PDF文件名 private final Properties chatFiles = new Properties(); /** * 保存文件到本地并记录映射关系 * @param chatId 会话ID * @param resource PDF文件资源 * @return 是否保存成功 */ @Override public boolean save(String chatId, Resource resource) { // 1. 保存文件到本地磁盘 String filename = resource.getFilename(); File target = new File(Objects.requireNonNull(filename)); // 避免重复保存已存在的文件 if (!target.exists()) { try { Files.copy(resource.getInputStream(), target.toPath()); } catch (IOException e) { log.error(\"PDF文件保存失败\", e); return false; } } // 2. 记录会话与文件的映射关系 chatFiles.put(chatId, filename); return true; } /** * 根据会话ID获取文件资源 * @param chatId 会话ID * @return 对应的PDF文件资源 */ @Override public Resource getFile(String chatId) { return new FileSystemResource(chatFiles.getProperty(chatId)); } /** * 初始化方法 - 在Bean创建后自动执行 * 功能: * 1. 加载历史会话文件映射 * 2. 恢复向量存储数据 */ @PostConstruct private void init() { // 1. 加载会话-文件映射关系 FileSystemResource pdfResource = new FileSystemResource(\"chat-pdf.properties\"); if (pdfResource.exists()) { try (BufferedReader reader = new BufferedReader( new InputStreamReader(pdfResource.getInputStream(), StandardCharsets.UTF_8))) { chatFiles.load(reader); } catch (IOException e) { throw new RuntimeException(\"会话映射关系加载失败\", e); } } // 2. 加载向量存储数据 FileSystemResource vectorResource = new FileSystemResource(\"chat-pdf.json\"); if (vectorResource.exists()) { SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore; simpleVectorStore.load(vectorResource); } } /** * 销毁方法 - 在Bean销毁前自动执行 * 功能: * 1. 持久化会话-文件映射关系 * 2. 保存向量存储数据 */ @PreDestroy private void persistent() { try { // 1. 保存会话-文件映射关系 chatFiles.store(new FileWriter(\"chat-pdf.properties\"), \"Last updated: \" + LocalDateTime.now()); // 2. 保存向量存储 SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore; simpleVectorStore.save(new File(\"chat-pdf.json\")); } catch (IOException e) { throw new RuntimeException(\"持久化数据失败\", e); } }}
PdfController 控制器接口类
import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.ai.chat.client.ChatClient;import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;import org.springframework.ai.document.Document;import org.springframework.ai.reader.ExtractedTextFormatter;import org.springframework.ai.reader.pdf.PagePdfDocumentReader;import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;import org.springframework.ai.vectorstore.VectorStore;import org.springframework.core.io.Resource;import org.springframework.http.MediaType;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;import reactor.core.publisher.Flux;import java.io.IOException;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import java.util.List;import java.util.Objects;import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;import static org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor.FILTER_EXPRESSION;@Slf4j@RequiredArgsConstructor@RestController@RequestMapping(\"/ai/pdf\")public class PdfController { private final FileRepository fileRepository; private final VectorStore vectorStore; private final ChatClient pdfChatClient; private final ChatHistoryRepository chatHistoryRepository; @RequestMapping(value = \"/chat\", produces = \"text/html;charset=utf-8\") public Flux chat(String prompt, String chatId) { // 1.找到会话文件 Resource file = fileRepository.getFile(chatId); if (!file.exists()) { // 文件不存在,不回答 throw new RuntimeException(\"会话文件不存在!\"); } // 2.保存会话id chatHistoryRepository.save(\"pdf\", chatId); // 3.请求模型 return pdfChatClient.prompt() .user(prompt) .advisors(a -> a.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)) .advisors(a -> a.param(FILTER_EXPRESSION, \"file_name == \'\" + file.getFilename() + \"\'\")) // 在向量库检索时只处理当前会话的文件 .stream() .content(); } /** * 文件上传 */ @RequestMapping(\"/upload/{chatId}\") public Result uploadPdf(@PathVariable String chatId, @RequestParam(\"file\") MultipartFile file) { try { // 1. 校验文件是否为PDF格式 if (!Objects.equals(file.getContentType(), \"application/pdf\")) { return Result.fail(\"只能上传PDF文件!\"); } // 2.保存文件 boolean success = fileRepository.save(chatId, file.getResource()); if (!success) { return Result.fail(\"保存文件失败!\"); } // 3.写入向量库 this.writeToVectorStore(file.getResource()); return Result.ok(); } catch (Exception e) { log.error(\"Failed to upload PDF.\", e); return Result.fail(\"上传文件失败!\"); } } /** * 文件下载 */ @GetMapping(\"/file/{chatId}\") public ResponseEntity download(@PathVariable(\"chatId\") String chatId) throws IOException { // 1.读取文件 Resource resource = fileRepository.getFile(chatId); if (!resource.exists()) { return ResponseEntity.notFound().build(); } // 2.文件名编码,写入响应头 String filename = URLEncoder.encode(Objects.requireNonNull(resource.getFilename()), StandardCharsets.UTF_8); // 3.返回文件 return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header(\"Content-Disposition\", \"attachment; filename=\\\"\" + filename + \"\\\"\") .body(resource); } private void writeToVectorStore(Resource resource) { // 1.创建PDF的读取器 PagePdfDocumentReader reader = new PagePdfDocumentReader( resource, // 文件源 PdfDocumentReaderConfig.builder() .withPageExtractedTextFormatter(ExtractedTextFormatter.defaults()) .withPagesPerDocument(1) // 每1页PDF作为一个Document .build() ); // 2.读取PDF文档,拆分为Document List documents = reader.read(); // 3.写入向量库 vectorStore.add(documents); }}
3.2 前端代码
可以根据这些代码与接口让Cursor生成一个上传pdf的AI问答页面,或者根据下列Vue项目代码修改实现(实现效果中的代码)
ChatPDF.vue
0\" class=\"selected-files\"> {{ file.name }} ({{ formatFileSize(file.size) }}) import { ref, onMounted, nextTick } from \'vue\'import { useDark } from \'@vueuse/core\'import { ChatBubbleLeftRightIcon, PaperAirplaneIcon, PlusIcon, PaperClipIcon, DocumentIcon, XMarkIcon} from \'@heroicons/vue/24/outline\'import ChatMessage from \'../components/ChatMessage.vue\'import { chatAPI } from \'../services/api\'const isDark = useDark()const messagesRef = ref(null)const inputRef = ref(null)const userInput = ref(\'\')const isStreaming = ref(false)const currentChatId = ref(null)const currentMessages = ref([])const chatHistory = ref([])const fileInput = ref(null)const selectedFiles = ref([])// 自动调整输入框高度const adjustTextareaHeight = () => { const textarea = inputRef.value if (textarea) { textarea.style.height = \'auto\' textarea.style.height = textarea.scrollHeight + \'px\' }else{ textarea.style.height = \'50px\' }}// 滚动到底部const scrollToBottom = async () => { await nextTick() if (messagesRef.value) { messagesRef.value.scrollTop = messagesRef.value.scrollHeight }}// 文件类型限制const FILE_LIMITS = { image: { maxSize: 10 * 1024 * 1024, // 单个文件 10MB maxFiles: 3, // 最多 3 个文件 description: \'图片文件\' }, audio: { maxSize: 10 * 1024 * 1024, // 单个文件 10MB maxDuration: 180, // 3分钟 maxFiles: 3, // 最多 3 个文件 description: \'音频文件\' }, video: { maxSize: 150 * 1024 * 1024, // 单个文件 150MB maxDuration: 40, // 40秒 maxFiles: 3, // 最多 3 个文件 description: \'视频文件\' }}// 触发文件选择const triggerFileInput = () => { fileInput.value?.click()}// 检查文件是否符合要求const validateFile = async (file) => { const type = file.type.split(\'/\')[0] const limit = FILE_LIMITS[type] if (!limit) { return { valid: false, error: \'不支持的文件类型\' } } if (file.size > limit.maxSize) { return { valid: false, error: `文件大小不能超过${limit.maxSize / 1024 / 1024}MB` } } if ((type === \'audio\' || type === \'video\') && limit.maxDuration) { try { const duration = await getMediaDuration(file) if (duration > limit.maxDuration) { return { valid: false, error: `${type === \'audio\' ? \'音频\' : \'视频\'}时长不能超过${limit.maxDuration}秒` } } } catch (error) { return { valid: false, error: \'无法读取媒体文件时长\' } } } return { valid: true }}// 获取媒体文件时长const getMediaDuration = (file) => { return new Promise((resolve, reject) => { const element = file.type.startsWith(\'audio/\') ? new Audio() : document.createElement(\'video\') element.preload = \'metadata\' element.onloadedmetadata = () => { resolve(element.duration) URL.revokeObjectURL(element.src) } element.onerror = () => { reject(new Error(\'无法读取媒体文件\')) URL.revokeObjectURL(element.src) } element.src = URL.createObjectURL(file) })}// 修改文件上传处理函数const handleFileUpload = async (event) => { const files = Array.from(event.target.files || []) if (!files.length) return // 检查所有文件类型是否一致 const firstFileType = files[0].type.split(\'/\')[0] const hasInconsistentType = files.some(file => file.type.split(\'/\')[0] !== firstFileType) if (hasInconsistentType) { alert(\'请选择相同类型的文件(图片、音频或视频)\') event.target.value = \'\' return } // 验证所有文件 for (const file of files) { const { valid, error } = await validateFile(file) if (!valid) { alert(error) event.target.value = \'\' selectedFiles.value = [] return } } // 检查文件总大小 const totalSize = files.reduce((sum, file) => sum + file.size, 0) const limit = FILE_LIMITS[firstFileType] if (totalSize > limit.maxSize * 3) { // 允许最多3个文件的总大小 alert(`${firstFileType === \'image\' ? \'图片\' : firstFileType === \'audio\' ? \'音频\' : \'视频\'}文件总大小不能超过${(limit.maxSize * 3) / 1024 / 1024}MB`) event.target.value = \'\' selectedFiles.value = [] return } selectedFiles.value = files}// 修改文件输入提示const getPlaceholder = () => { if (selectedFiles.value.length > 0) { const type = selectedFiles.value[0].type.split(\'/\')[0] const desc = FILE_LIMITS[type].description return `已选择 ${selectedFiles.value.length} 个${desc},可继续输入消息...` } return \'输入消息,可上传图片、音频或视频...\'}// 修改发送消息函数const sendMessage = async () => { if (isStreaming.value) return if (!userInput.value.trim() && !selectedFiles.value.length) return const messageContent = userInput.value.trim() // 添加用户消息 const userMessage = { role: \'user\', content: messageContent, timestamp: new Date() } currentMessages.value.push(userMessage) // 清空输入 userInput.value = \'\' adjustTextareaHeight() await scrollToBottom() // 准备发送数据 const formData = new FormData() if (messageContent) { formData.append(\'prompt\', messageContent) } selectedFiles.value.forEach(file => { formData.append(\'files\', file) }) // 添加助手消息占位 const assistantMessage = { role: \'assistant\', content: \'\', timestamp: new Date() } currentMessages.value.push(assistantMessage) isStreaming.value = true try { const reader = await chatAPI.sendMessage(formData, currentChatId.value) const decoder = new TextDecoder(\'utf-8\') let accumulatedContent = \'\' // 添加累积内容变量 while (true) { try { const { value, done } = await reader.read() if (done) break // 累积新内容 accumulatedContent += decoder.decode(value) // 追加新内容 await nextTick(() => { // 更新消息,使用累积的内容 const updatedMessage = { ...assistantMessage, content: accumulatedContent // 使用累积的内容 } const lastIndex = currentMessages.value.length - 1 currentMessages.value.splice(lastIndex, 1, updatedMessage) }) await scrollToBottom() } catch (readError) { console.error(\'读取流错误:\', readError) break } } } catch (error) { console.error(\'发送消息失败:\', error) assistantMessage.content = \'抱歉,发生了错误,请稍后重试。\' } finally { isStreaming.value = false selectedFiles.value = [] // 清空已选文件 fileInput.value.value = \'\' // 清空文件输入 await scrollToBottom() }}// 加载特定对话const loadChat = async (chatId) => { currentChatId.value = chatId try { const messages = await chatAPI.getChatMessages(chatId, \'chat\') currentMessages.value = messages } catch (error) { console.error(\'加载对话消息失败:\', error) currentMessages.value = [] }}// 加载聊天历史const loadChatHistory = async () => { try { const history = await chatAPI.getChatHistory(\'chat\') chatHistory.value = history || [] if (history && history.length > 0) { await loadChat(history[0].id) } else { startNewChat() } } catch (error) { console.error(\'加载聊天历史失败:\', error) chatHistory.value = [] startNewChat() }}// 开始新对话const startNewChat = () => { const newChatId = Date.now().toString() currentChatId.value = newChatId currentMessages.value = [] // 添加新对话到聊天历史列表 const newChat = { id: newChatId, title: `对话 ${newChatId.slice(-6)}` } chatHistory.value = [newChat, ...chatHistory.value] // 将新对话添加到列表开头}// 格式化文件大小const formatFileSize = (bytes) => { if (bytes < 1024) return bytes + \' B\' if (bytes { selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index) if (selectedFiles.value.length === 0) { fileInput.value.value = \'\' // 清空文件输入 }}onMounted(() => { loadChatHistory() adjustTextareaHeight()}).ai-chat { position: fixed; // 修改为固定定位 top: 64px; // 导航栏高度 left: 0; right: 0; bottom: 0; display: flex; background: var(--bg-color); overflow: hidden; // 防止页面滚动 .chat-container { flex: 1; display: flex; max-width: 1800px; width: 100%; margin: 0 auto; padding: 1.5rem 2rem; gap: 1.5rem; height: 100%; // 确保容器占满高度 overflow: hidden; // 防止容器滚动 } .sidebar { width: 300px; display: flex; flex-direction: column; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 1rem; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); .history-header { flex-shrink: 0; // 防止头部压缩 padding: 1rem; display: flex; justify-content: space-between; align-items: center; h2 { font-size: 1.25rem; } .new-chat { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-radius: 0.5rem; background: #007CF0; color: white; border: none; cursor: pointer; transition: background-color 0.3s; &:hover { background: #0066cc; } .icon { width: 1.25rem; height: 1.25rem; } } } .history-list { flex: 1; overflow-y: auto; // 允许历史记录滚动 padding: 0 1rem 1rem; .history-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-radius: 0.5rem; cursor: pointer; transition: background-color 0.3s; &:hover { background: rgba(255, 255, 255, 0.1); } &.active { background: rgba(0, 124, 240, 0.1); } .icon { width: 1.25rem; height: 1.25rem; } .title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } } } .chat-main { flex: 1; display: flex; flex-direction: column; background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-radius: 1rem; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); overflow: hidden; // 防止内容溢出 .messages { flex: 1; overflow-y: auto; // 只允许消息区域滚动 padding: 2rem; } .input-area { flex-shrink: 0; padding: 1.5rem 2rem; background: rgba(255, 255, 255, 0.98); border-top: 1px solid rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; gap: 1rem; .selected-files { background: rgba(0, 0, 0, 0.02); border-radius: 0.75rem; padding: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.05); .file-item { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background: #fff; border-radius: 0.5rem; margin-bottom: 0.75rem; border: 1px solid rgba(0, 0, 0, 0.05); transition: all 0.2s ease; &:last-child { margin-bottom: 0; } &:hover { background: rgba(0, 124, 240, 0.02); border-color: rgba(0, 124, 240, 0.2); } .file-info { display: flex; align-items: center; gap: 0.75rem; .icon { width: 1.5rem; height: 1.5rem; color: #007CF0; } .file-name { font-size: 0.875rem; color: #333; font-weight: 500; } .file-size { font-size: 0.75rem; color: #666; background: rgba(0, 0, 0, 0.05); padding: 0.25rem 0.5rem; border-radius: 1rem; } } .remove-btn { padding: 0.375rem; border: none; background: rgba(0, 0, 0, 0.05); color: #666; cursor: pointer; border-radius: 0.375rem; transition: all 0.2s ease; &:hover { background: #ff4d4f; color: #fff; } .icon { width: 1.25rem; height: 1.25rem; } } } } .input-row { display: flex; gap: 1rem; align-items: flex-end; background: #fff; padding: 0.75rem; border-radius: 1rem; border: 1px solid rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); .file-upload { .hidden { display: none; } .upload-btn { width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; border: none; border-radius: 0.75rem; background: rgba(0, 124, 240, 0.1); color: #007CF0; cursor: pointer; transition: all 0.2s ease; &:hover:not(:disabled) { background: rgba(0, 124, 240, 0.2); } &:disabled { opacity: 0.5; cursor: not-allowed; } .icon { width: 1.25rem; height: 1.25rem; } } } textarea { flex: 1; resize: none; border: none; background: transparent; padding: 0.75rem; color: inherit; font-family: inherit; font-size: 1rem; line-height: 1.5; max-height: 150px; &:focus { outline: none; } &::placeholder { color: #999; } } .send-button { width: 2.5rem; height: 2.5rem; display: flex; align-items: center; justify-content: center; border: none; border-radius: 0.75rem; background: #007CF0; color: white; cursor: pointer; transition: all 0.2s ease; &:hover:not(:disabled) { background: #0066cc; transform: translateY(-1px); } &:disabled { background: #ccc; cursor: not-allowed; } .icon { width: 1.25rem; height: 1.25rem; } } } } }}.dark { .sidebar { background: rgba(40, 40, 40, 0.95); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); } .chat-main { background: rgba(40, 40, 40, 0.95); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); .input-area { background: rgba(30, 30, 30, 0.98); border-top: 1px solid rgba(255, 255, 255, 0.05); .selected-files { background: rgba(255, 255, 255, 0.02); border-color: rgba(255, 255, 255, 0.05); .file-item { background: rgba(255, 255, 255, 0.02); border-color: rgba(255, 255, 255, 0.05); &:hover { background: rgba(0, 124, 240, 0.1); border-color: rgba(0, 124, 240, 0.3); } .file-info { .icon { color: #007CF0; } .file-name { color: #fff; } .file-size { color: #999; background: rgba(255, 255, 255, 0.1); } } .remove-btn { background: rgba(255, 255, 255, 0.1); color: #999; &:hover { background: #ff4d4f; color: #fff; } } } } .input-row { background: rgba(255, 255, 255, 0.02); border-color: rgba(255, 255, 255, 0.05); box-shadow: none; textarea { color: #fff; &::placeholder { color: #666; } } .file-upload .upload-btn { background: rgba(0, 124, 240, 0.2); color: #007CF0; &:hover:not(:disabled) { background: rgba(0, 124, 240, 0.3); } } } } } .history-item { &:hover { background: rgba(255, 255, 255, 0.05) !important; } &.active { background: rgba(0, 124, 240, 0.2) !important; } } textarea { background: rgba(255, 255, 255, 0.05) !important; &:focus { background: rgba(255, 255, 255, 0.1) !important; } } .input-area { .file-upload { .upload-btn { background: rgba(255, 255, 255, 0.1); color: #999; &:hover:not(:disabled) { background: rgba(255, 255, 255, 0.2); color: #fff; } } } }}@media (max-width: 768px) { .ai-chat { .chat-container { padding: 0; } .sidebar { display: none; // 在移动端隐藏侧边栏 } .chat-main { border-radius: 0; } }}
ChatMessage.vue
${marked.parse(currentBlock)}` currentBlock = \'\' i += 7 // 跳过 continue } currentBlock += content[i] } // 处理剩余内容 if (currentBlock) { if (isInThinkBlock) { result += `${marked.parse(currentBlock)}` } else { result += marked.parse(currentBlock) } } // 净化处理后的 HTML const cleanHtml = DOMPurify.sanitize(result, { ADD_TAGS: [\'think\', \'code\', \'pre\', \'span\'], ADD_ATTR: [\'class\', \'language\'] }) // 在净化后的 HTML 中查找代码块并添加复制按钮 const tempDiv = document.createElement(\'div\') tempDiv.innerHTML = cleanHtml // 查找所有代码块 const preElements = tempDiv.querySelectorAll(\'pre\') preElements.forEach(pre => { const code = pre.querySelector(\'code\') if (code) { // 创建包装器 const wrapper = document.createElement(\'div\') wrapper.className = \'code-block-wrapper\' // 添加复制按钮 const copyBtn = document.createElement(\'button\') copyBtn.className = \'code-copy-button\' copyBtn.title = \'复制代码\' copyBtn.innerHTML = ` ` // 添加成功消息 const successMsg = document.createElement(\'div\') successMsg.className = \'copy-success-message\' successMsg.textContent = \'已复制!\' // 组装结构 wrapper.appendChild(copyBtn) wrapper.appendChild(pre.cloneNode(true)) wrapper.appendChild(successMsg) // 替换原始的 pre 元素 pre.parentNode.replaceChild(wrapper, pre) } }) return tempDiv.innerHTML}// 修改计算属性const processedContent = computed(() => { if (!props.message.content) return \'\' return processContent(props.message.content)})// 为代码块添加复制功能const setupCodeBlockCopyButtons = () => { if (!contentRef.value) return; const codeBlocks = contentRef.value.querySelectorAll(\'.code-block-wrapper\'); codeBlocks.forEach(block => { const copyButton = block.querySelector(\'.code-copy-button\'); const codeElement = block.querySelector(\'code\'); const successMessage = block.querySelector(\'.copy-success-message\'); if (copyButton && codeElement) { // 移除旧的事件监听器 const newCopyButton = copyButton.cloneNode(true); copyButton.parentNode.replaceChild(newCopyButton, copyButton); // 添加新的事件监听器 newCopyButton.addEventListener(\'click\', async (e) => { e.preventDefault(); e.stopPropagation(); try { const code = codeElement.textContent || \'\'; await navigator.clipboard.writeText(code); // 显示成功消息 if (successMessage) { successMessage.classList.add(\'visible\'); setTimeout(() => { successMessage.classList.remove(\'visible\'); }, 2000); } } catch (err) { console.error(\'复制代码失败:\', err); } }); } });}// 在内容更新后手动应用高亮和设置复制按钮const highlightCode = async () => { await nextTick() if (contentRef.value) { contentRef.value.querySelectorAll(\'pre code\').forEach((block) => { hljs.highlightElement(block) }) // 设置代码块复制按钮 setupCodeBlockCopyButtons() }}const props = defineProps({ message: { type: Object, required: true }})const isUser = computed(() => props.message.role === \'user\')// 复制内容到剪贴板const copyContent = async () => { try { // 获取纯文本内容 let textToCopy = props.message.content; // 如果是AI回复,需要去除HTML标签 if (!isUser.value && contentRef.value) { // 创建临时元素来获取纯文本 const tempDiv = document.createElement(\'div\'); tempDiv.innerHTML = processedContent.value; textToCopy = tempDiv.textContent || tempDiv.innerText || \'\'; } await navigator.clipboard.writeText(textToCopy); copied.value = true; // 3秒后重置复制状态 setTimeout(() => { copied.value = false; }, 3000); } catch (err) { console.error(\'复制失败:\', err); }}// 监听内容变化watch(() => props.message.content, () => { if (!isUser.value) { highlightCode() }})// 初始化时也执行一次onMounted(() => { if (!isUser.value) { highlightCode() }})const formatTime = (timestamp) => { if (!timestamp) return \'\' return new Date(timestamp).toLocaleTimeString()}.message { display: flex; margin-bottom: 1.5rem; gap: 1rem; &.message-user { flex-direction: row-reverse; .content { align-items: flex-end; .text-container { position: relative; .text { background: #f0f7ff; // 浅色背景 color: #333; border-radius: 1rem 1rem 0 1rem; } .user-copy-button { position: absolute; left: -30px; top: 50%; transform: translateY(-50%); background: transparent; border: none; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; cursor: pointer; opacity: 0; transition: opacity 0.2s; .copy-icon { width: 16px; height: 16px; color: #666; &.copied { color: #4ade80; } } } &:hover .user-copy-button { opacity: 1; } } .message-footer { flex-direction: row-reverse; } } } .avatar { width: 40px; height: 40px; flex-shrink: 0; .icon { width: 100%; height: 100%; color: #666; padding: 4px; border-radius: 8px; transition: all 0.3s ease; &.assistant { color: #333; background: #f0f0f0; &:hover { background: #e0e0e0; transform: scale(1.05); } } } } .content { display: flex; flex-direction: column; gap: 0.25rem; max-width: 80%; .text-container { position: relative; } .message-footer { display: flex; align-items: center; margin-top: 0.25rem; .time { font-size: 0.75rem; color: #666; } .copy-button { display: flex; align-items: center; gap: 0.25rem; background: transparent; border: none; font-size: 0.75rem; color: #666; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; margin-right: auto; transition: background-color 0.2s; &:hover { background-color: rgba(0, 0, 0, 0.05); } .copy-icon { width: 14px; height: 14px; &.copied { color: #4ade80; } } .copy-text { font-size: 0.75rem; } } } .text { padding: 1rem; border-radius: 1rem 1rem 1rem 0; line-height: 1.5; white-space: pre-wrap; color: var(--text-color); .cursor { animation: blink 1s infinite; } :deep(.think-block) { position: relative; padding: 0.75rem 1rem 0.75rem 1.5rem; margin: 0.5rem 0; color: #666; font-style: italic; border-left: 4px solid #ddd; background-color: rgba(0, 0, 0, 0.03); border-radius: 0 0.5rem 0.5rem 0; // 添加平滑过渡效果 opacity: 1; transform: translateX(0); transition: opacity 0.3s ease, transform 0.3s ease; &::before { content: \'思考\'; position: absolute; top: -0.75rem; left: 1rem; padding: 0 0.5rem; font-size: 0.75rem; background: #f5f5f5; border-radius: 0.25rem; color: #999; font-style: normal; } // 添加进入动画 &:not(:first-child) { animation: slideIn 0.3s ease forwards; } } :deep(pre) { background: #f6f8fa; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 0.5rem 0; border: 1px solid #e1e4e8; code { background: transparent; padding: 0; font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; font-size: 0.9rem; line-height: 1.5; tab-size: 2; } } :deep(.hljs) { color: #24292e; background: transparent; } :deep(.hljs-keyword) { color: #d73a49; } :deep(.hljs-built_in) { color: #005cc5; } :deep(.hljs-type) { color: #6f42c1; } :deep(.hljs-literal) { color: #005cc5; } :deep(.hljs-number) { color: #005cc5; } :deep(.hljs-regexp) { color: #032f62; } :deep(.hljs-string) { color: #032f62; } :deep(.hljs-subst) { color: #24292e; } :deep(.hljs-symbol) { color: #e36209; } :deep(.hljs-class) { color: #6f42c1; } :deep(.hljs-function) { color: #6f42c1; } :deep(.hljs-title) { color: #6f42c1; } :deep(.hljs-params) { color: #24292e; } :deep(.hljs-comment) { color: #6a737d; } :deep(.hljs-doctag) { color: #d73a49; } :deep(.hljs-meta) { color: #6a737d; } :deep(.hljs-section) { color: #005cc5; } :deep(.hljs-name) { color: #22863a; } :deep(.hljs-attribute) { color: #005cc5; } :deep(.hljs-variable) { color: #e36209; } } }}@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; }}@keyframes slideIn { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: translateX(0); }}.dark { .message { .avatar .icon { &.assistant { color: #fff; background: #444; &:hover { background: #555; } } } &.message-user { .content .text-container { .text { background: #1a365d; // 暗色模式下的浅蓝色背景 color: #fff; } .user-copy-button { .copy-icon { color: #999; &.copied { color: #4ade80; } } } } } .content { .message-footer { .time { color: #999; } .copy-button { color: #999; &:hover { background-color: rgba(255, 255, 255, 0.1); } } } .text { :deep(.think-block) { background-color: rgba(255, 255, 255, 0.03); border-left-color: #666; color: #999; &::before { background: #2a2a2a; color: #888; } } :deep(pre) { background: #161b22; border-color: #30363d; code { color: #c9d1d9; } } :deep(.hljs) { color: #c9d1d9; background: transparent; } :deep(.hljs-keyword) { color: #ff7b72; } :deep(.hljs-built_in) { color: #79c0ff; } :deep(.hljs-type) { color: #ff7b72; } :deep(.hljs-literal) { color: #79c0ff; } :deep(.hljs-number) { color: #79c0ff; } :deep(.hljs-regexp) { color: #a5d6ff; } :deep(.hljs-string) { color: #a5d6ff; } :deep(.hljs-subst) { color: #c9d1d9; } :deep(.hljs-symbol) { color: #ffa657; } :deep(.hljs-class) { color: #f2cc60; } :deep(.hljs-function) { color: #d2a8ff; } :deep(.hljs-title) { color: #d2a8ff; } :deep(.hljs-params) { color: #c9d1d9; } :deep(.hljs-comment) { color: #8b949e; } :deep(.hljs-doctag) { color: #ff7b72; } :deep(.hljs-meta) { color: #8b949e; } :deep(.hljs-section) { color: #79c0ff; } :deep(.hljs-name) { color: #7ee787; } :deep(.hljs-attribute) { color: #79c0ff; } :deep(.hljs-variable) { color: #ffa657; } } &.message-user .content .text { background: #0066cc; color: white; } } }}.markdown-content { :deep(p) { margin: 0.5rem 0; &:first-child { margin-top: 0; } &:last-child { margin-bottom: 0; } } :deep(ul), :deep(ol) { margin: 0.5rem 0; padding-left: 1.5rem; } :deep(li) { margin: 0.25rem 0; } :deep(code) { background: rgba(0, 0, 0, 0.05); padding: 0.2em 0.4em; border-radius: 3px; font-size: 0.9em; font-family: ui-monospace, monospace; } :deep(pre code) { background: transparent; padding: 0; } :deep(table) { border-collapse: collapse; margin: 0.5rem 0; width: 100%; } :deep(th), :deep(td) { border: 1px solid #ddd; padding: 0.5rem; text-align: left; } :deep(th) { background: rgba(0, 0, 0, 0.05); } :deep(blockquote) { margin: 0.5rem 0; padding-left: 1rem; border-left: 4px solid #ddd; color: #666; } :deep(.code-block-wrapper) { position: relative; margin: 1rem 0; border-radius: 6px; overflow: hidden; .code-copy-button { position: absolute; top: 0.5rem; right: 0.5rem; background: rgba(255, 255, 255, 0.1); border: none; color: #e6e6e6; cursor: pointer; padding: 0.25rem; border-radius: 4px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s, background-color 0.2s; z-index: 10; &:hover { background-color: rgba(255, 255, 255, 0.2); } .code-copy-icon { width: 16px; height: 16px; } } &:hover .code-copy-button { opacity: 0.8; } pre { margin: 0; padding: 1rem; background: #1e1e1e; overflow-x: auto; code { background: transparent; padding: 0; font-family: ui-monospace, monospace; } } .copy-success-message { position: absolute; top: 0.5rem; right: 0.5rem; background: rgba(74, 222, 128, 0.9); color: white; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; opacity: 0; transform: translateY(-10px); transition: opacity 0.3s, transform 0.3s; pointer-events: none; z-index: 20; &.visible { opacity: 1; transform: translateY(0); } } }}.dark { .markdown-content { :deep(.code-block-wrapper) { .code-copy-button { background: rgba(255, 255, 255, 0.05); &:hover { background-color: rgba(255, 255, 255, 0.1); } } pre { background: #0d0d0d; } } :deep(code) { background: rgba(255, 255, 255, 0.1); } :deep(th), :deep(td) { border-color: #444; } :deep(th) { background: rgba(255, 255, 255, 0.1); } :deep(blockquote) { border-left-color: #444; color: #999; } }}
import { computed, onMounted, nextTick, ref, watch } from \'vue\'import { marked } from \'marked\'import DOMPurify from \'dompurify\'import { UserCircleIcon, ComputerDesktopIcon, DocumentDuplicateIcon, CheckIcon } from \'@heroicons/vue/24/outline\'import hljs from \'highlight.js\'import \'highlight.js/styles/github-dark.css\'const contentRef = ref(null)const copied = ref(false)const copyButtonTitle = computed(() => copied.value ? \'已复制\' : \'复制内容\')// 配置 markedmarked.setOptions({ breaks: true, gfm: true, sanitize: false})// 处理内容const processContent = (content) => { if (!content) return \'\' // 分析内容中的 think 标签 let result = \'\' let isInThinkBlock = false let currentBlock = \'\' // 逐字符分析,处理 think 标签 for (let i = 0; i < content.length; i++) { if (content.slice(i, i + 7) === \'\') { isInThinkBlock = true if (currentBlock) { // 将之前的普通内容转换为 HTML result += marked.parse(currentBlock) } currentBlock = \'\' i += 6 // 跳过 continue } if (content.slice(i, i + 8) === \'\') { isInThinkBlock = false // 将 think 块包装在特殊 div 中 result += `
PDFViewer.vue
{{ fileName }} 正在加载 PDF...
import { ref, watch, onMounted, onUnmounted, computed } from \'vue\'import { DocumentTextIcon } from \'@heroicons/vue/24/outline\'import { useDark } from \'@vueuse/core\'const isDark = useDark()const props = defineProps({ file: { type: [File, null], default: null }, fileName: { type: String, default: \'\' }})const isLoading = ref(false)const viewerRef = ref(null)let instance = null// 使用简单的 PDF.js 实现onMounted(async () => { if (viewerRef.value && props.file) { try { isLoading.value = true // 创建 iframe 元素 const iframe = document.createElement(\'iframe\') iframe.style.width = \'100%\' iframe.style.height = \'100%\' iframe.style.border = \'none\' // 创建 Blob URL const url = URL.createObjectURL(props.file) iframe.src = url // 清空容器并添加 iframe viewerRef.value.innerHTML = \'\' viewerRef.value.appendChild(iframe) // 监听 iframe 加载完成 iframe.onload = () => { isLoading.value = false } // 保存 URL 以便清理 instance = { url } } catch (error) { console.error(\'PDF 查看器初始化失败:\', error) isLoading.value = false } }})// 创建 iframe 并设置主题const createIframe = (file) => { const iframe = document.createElement(\'iframe\') iframe.style.width = \'100%\' iframe.style.height = \'100%\' iframe.style.border = \'none\' // 创建 Blob URL const url = URL.createObjectURL(file) // 根据当前主题设置 iframe 的背景色 if (isDark.value) { iframe.style.backgroundColor = \'#1a1a1a\' } else { iframe.style.backgroundColor = \'#ffffff\' } iframe.src = url return { iframe, url }}// 监听文件变化watch(() => props.file, (newFile) => { if (newFile) { // 重新挂载组件 if (instance?.url) { URL.revokeObjectURL(instance.url) } try { isLoading.value = true const { iframe, url } = createIframe(newFile) // 清空容器并添加 iframe if (viewerRef.value) { viewerRef.value.innerHTML = \'\' viewerRef.value.appendChild(iframe) } // 监听 iframe 加载完成 iframe.onload = () => { isLoading.value = false } // 保存 URL 以便清理 instance = { url, iframe } } catch (error) { console.error(\'加载 PDF 失败:\', error) isLoading.value = false } }})// 监听主题变化watch(() => isDark.value, (newIsDark) => { if (instance?.iframe) { if (newIsDark) { instance.iframe.style.backgroundColor = \'#1a1a1a\' } else { instance.iframe.style.backgroundColor = \'#ffffff\' } }})onUnmounted(() => { if (instance?.url) { URL.revokeObjectURL(instance.url) }}).pdf-view { flex: 1; display: flex; flex-direction: column; border-right: 1px solid rgba(0, 0, 0, 0.1); background: #fff; .pdf-header { padding: 1rem; display: flex; align-items: center; gap: 1rem; border-bottom: 1px solid rgba(0, 0, 0, 0.1); background: rgba(255, 255, 255, 0.98); z-index: 1; .icon { width: 1.5rem; height: 1.5rem; color: #666; } .filename { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } } .pdf-content { flex: 1; position: relative; overflow: hidden; .pdf-container { width: 100%; height: 100%; } .pdf-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 1rem; background: rgba(255, 255, 255, 0.9); padding: 2rem; border-radius: 1rem; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); z-index: 2; .loading-spinner { width: 48px; height: 48px; border: 4px solid rgba(0, 124, 240, 0.1); border-left-color: #007CF0; border-radius: 50%; animation: spin 1s linear infinite; } .loading-text { color: #666; font-size: 1rem; font-weight: 500; } } }}// 暗色模式支持.dark { .pdf-view { background: #1a1a1a; border-right-color: rgba(255, 255, 255, 0.1); .pdf-header { background: rgba(30, 30, 30, 0.98); border-bottom-color: rgba(255, 255, 255, 0.1); .icon { color: #999; } .filename { color: #fff; } } .pdf-content { background: #0d0d0d; .pdf-loading { background: rgba(30, 30, 30, 0.9); .loading-spinner { border-color: rgba(0, 124, 240, 0.2); border-left-color: #007CF0; } .loading-text { color: #999; } } } }}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }}
api.js 接口调用js
const BASE_URL = \'http://localhost:8080\'export const chatAPI = { // 发送 PDF 问答消息 async sendPdfMessage(prompt, chatId) { try { const response = await fetch(`${BASE_URL}/ai/pdf/chat?prompt=${encodeURIComponent(prompt)}&chatId=${chatId}`, { method: \'GET\', // 确保使用流式响应 signal: AbortSignal.timeout(30000) // 30秒超时 }) if (!response.ok) { throw new Error(`API error: ${response.status}`) } // 返回可读流 return response.body.getReader() } catch (error) { console.error(\'API Error:\', error) throw error } }}
如果有什么疑问或者建议欢迎评论区留言讨论!