【SpringAI實戰】ChatPDF實現RAG知識庫

一、前言

二、實現效果

三、代碼實現

????????3.1 后端代碼

????????3.2 前端代碼


一、前言

Spring AI詳解:【Spring AI詳解】開啟Java生態的智能應用開發新時代(附不同功能的Spring AI實戰項目)-CSDN博客

二、實現效果

實現一個非常火爆的個人知識庫AI應用,ChatPDF,原網站如下:

這個網站其實就是把你個人的PDF文件作為知識庫,讓AI基于PDF內容來回答你的問題,對于大學生、研究人員、專業人士來說,非常方便。

我們下面代碼的實現效果:

三、代碼實現

3.1 后端代碼

pom.xml

    <!-- 繼承Spring Boot父POM,提供默認依賴管理 --><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.4.3</version> <!-- Spring Boot版本 --><relativePath/> <!-- 優先從本地倉庫查找 --></parent><!-- 自定義屬性 --><properties><java.version>17</java.version> <!-- JDK版本要求 --><spring-ai.version>1.0.0-M6</spring-ai.version> <!-- Spring AI里程碑版本 --></properties><!-- 項目依賴 --><dependencies><!-- Spring Boot Web支持 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- AI相關依賴 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-ollama-spring-boot-starter</artifactId> <!-- Ollama集成 --></dependency><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-openai-spring-boot-starter</artifactId> <!-- OpenAI集成 --><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-pdf-document-reader</artifactId> <!-- PDF文檔處理 --></dependency></dependency><!-- 開發工具 --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.22</version> <!-- 注解簡化代碼 --><scope>provided</scope> <!-- 編譯期使用 --></dependency></dependencies><!-- 依賴管理(統一Spring AI家族版本) --><dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope> <!-- 導入BOM管理版本 --></dependency></dependencies></dependencyManagement>

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-v3dimensions: 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)*/
@Configuration
public class CommonConfiguration {/*** 內存式聊天記憶存儲* @return InMemoryChatMemory 實例** 作用:保存對話上下文,實現多輪對話能力* 實現原理:基于ConcurrentHashMap的線程安全實現*/@Beanpublic ChatMemory chatMemory() {return new InMemoryChatMemory();}/*** 向量存儲配置* @param embeddingModel 嵌入模型(用于文本向量化)* @return SimpleVectorStore 實例** 應用場景:* - 文檔語義搜索* - PDF內容檢索*/@Beanpublic VectorStore vectorStore(OpenAiEmbeddingModel embeddingModel) {return SimpleVectorStore.builder(embeddingModel).build();}/*** PDF文檔問答客戶端* @param model OpenAI模型* @param chatMemory 聊天記憶* @param vectorStore 向量存儲* @return PDF專用ChatClient** 核心機制:* - 基于向量相似度檢索(相似度閾值0.6,返回Top2結果)*/@Beanpublic 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<String> getChatIds(String type);
}

InMemoryChatHistoryRepository 會話歷史實現類

@Slf4j
@Component
@RequiredArgsConstructor
public class InMemoryChatHistoryRepository implements ChatHistoryRepository {// 會話chatId存儲Mapprivate Map<String, List<String>> chatHistory;private final ChatMemory chatMemory;// 保存會話ID@Overridepublic void save(String type, String chatId) {/*if (!chatHistory.containsKey(type)) {chatHistory.put(type, new ArrayList<>());}List<String> chatIds = chatHistory.get(type);*/List<String> chatIds = chatHistory.computeIfAbsent(type, k -> new ArrayList<>());if (chatIds.contains(chatId)) {return;}chatIds.add(chatId);}// 獲取所有會話id@Overridepublic List<String> getChatIds(String type) {/*List<String> 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
@RequiredArgsConstructor
public 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 是否保存成功*/@Overridepublic 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文件資源*/@Overridepublic Resource getFile(String chatId) {return new FileSystemResource(chatFiles.getProperty(chatId));}/*** 初始化方法 - 在Bean創建后自動執行* 功能:* 1. 加載歷史會話文件映射* 2. 恢復向量存儲數據*/@PostConstructprivate 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. 保存向量存儲數據*/@PreDestroyprivate 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<String> chat(String prompt, String chatId) {// 1.找到會話文件Resource file = fileRepository.getFile(chatId);if (!file.exists()) {// 文件不存在,不回答throw new RuntimeException("會話文件不存在!");}// 2.保存會話idchatHistoryRepository.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<Resource> 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文檔,拆分為DocumentList<Document> documents = reader.read();// 3.寫入向量庫vectorStore.add(documents);}
}

3.2 前端代碼

可以根據這些代碼與接口讓Cursor生成一個上傳pdf的AI問答頁面,或者根據下列Vue項目代碼修改實現(實現效果中的代碼)?

ChatPDF.vue

<template><div class="ai-chat" :class="{ 'dark': isDark }"><div class="chat-container"><div class="sidebar"><div class="history-header"><h2>聊天記錄</h2><button class="new-chat" @click="startNewChat"><PlusIcon class="icon" />新對話</button></div><div class="history-list"><div v-for="chat in chatHistory" :key="chat.id"class="history-item":class="{ 'active': currentChatId === chat.id }"@click="loadChat(chat.id)"><ChatBubbleLeftRightIcon class="icon" /><span class="title">{{ chat.title || '新對話' }}</span></div></div></div><div class="chat-main"><div class="messages" ref="messagesRef"><ChatMessagev-for="(message, index) in currentMessages":key="index":message="message":is-stream="isStreaming && index === currentMessages.length - 1"/></div><div class="input-area"><div v-if="selectedFiles.length > 0" class="selected-files"><div v-for="(file, index) in selectedFiles" :key="index" class="file-item"><div class="file-info"><DocumentIcon class="icon" /><span class="file-name">{{ file.name }}</span><span class="file-size">({{ formatFileSize(file.size) }})</span></div><button class="remove-btn" @click="removeFile(index)"><XMarkIcon class="icon" /></button></div></div><div class="input-row"><div class="file-upload"><input type="file" ref="fileInput"@change="handleFileUpload"accept="image/*,audio/*,video/*"multipleclass="hidden"><button class="upload-btn"@click="triggerFileInput":disabled="isStreaming"><PaperClipIcon class="icon" /></button></div><textareav-model="userInput"@keydown.enter.prevent="sendMessage":placeholder="getPlaceholder()"rows="1"ref="inputRef"></textarea><button class="send-button" @click="sendMessage":disabled="isStreaming || (!userInput.trim() && !selectedFiles.length)"><PaperAirplaneIcon class="icon" /></button></div></div></div></div></div>
</template><script setup>
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.valueif (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,  // 單個文件 10MBmaxFiles: 3,                 // 最多 3 個文件description: '圖片文件'},audio: { maxSize: 10 * 1024 * 1024,  // 單個文件 10MBmaxDuration: 180,           // 3分鐘maxFiles: 3,                // 最多 3 個文件description: '音頻文件'},video: { maxSize: 150 * 1024 * 1024, // 單個文件 150MBmaxDuration: 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].descriptionreturn `已選擇 ${selectedFiles.value.length} 個${desc},可繼續輸入消息...`}return '輸入消息,可上傳圖片、音頻或視頻...'
}// 修改發送消息函數
const sendMessage = async () => {if (isStreaming.value) returnif (!userInput.value.trim() && !selectedFiles.value.length) returnconst 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 = truetry {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 - 1currentMessages.value.splice(lastIndex, 1, updatedMessage)})await scrollToBottom()} catch (readError) {console.error('讀取流錯誤:', readError)break}}} catch (error) {console.error('發送消息失敗:', error)assistantMessage.content = '抱歉,發生了錯誤,請稍后重試。'} finally {isStreaming.value = falseselectedFiles.value = [] // 清空已選文件fileInput.value.value = '' // 清空文件輸入await scrollToBottom()}
}// 加載特定對話
const loadChat = async (chatId) => {currentChatId.value = chatIdtry {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 = newChatIdcurrentMessages.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 < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}// 移除文件
const removeFile = (index) => {selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)if (selectedFiles.value.length === 0) {fileInput.value.value = ''  // 清空文件輸入}
}onMounted(() => {loadChatHistory()adjustTextareaHeight()
})
</script><style scoped lang="scss">
.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;}}
}
</style> 

?ChatMessage.vue?

<template><div class="message" :class="{ 'message-user': isUser }"><div class="avatar"><UserCircleIcon v-if="isUser" class="icon" /><ComputerDesktopIcon v-else class="icon" :class="{ 'assistant': !isUser }" /></div><div class="content"><div class="text-container"><button v-if="isUser" class="user-copy-button" @click="copyContent" :title="copyButtonTitle"><DocumentDuplicateIcon v-if="!copied" class="copy-icon" /><CheckIcon v-else class="copy-icon copied" /></button><div class="text" ref="contentRef" v-if="isUser">{{ message.content }}</div><div class="text markdown-content" ref="contentRef" v-else v-html="processedContent"></div></div><div class="message-footer" v-if="!isUser"><button class="copy-button" @click="copyContent" :title="copyButtonTitle"><DocumentDuplicateIcon v-if="!copied" class="copy-icon" /><CheckIcon v-else class="copy-icon copied" /></button></div></div></div>
</template><script setup>
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 ? '已復制' : '復制內容')// 配置 marked
marked.setOptions({breaks: true,gfm: true,sanitize: false
})// 處理內容
const processContent = (content) => {if (!content) return ''// 分析內容中的 think 標簽let result = ''let isInThinkBlock = falselet currentBlock = ''// 逐字符分析,處理 think 標簽for (let i = 0; i < content.length; i++) {if (content.slice(i, i + 7) === '<think>') {isInThinkBlock = trueif (currentBlock) {// 將之前的普通內容轉換為 HTMLresult += marked.parse(currentBlock)}currentBlock = ''i += 6 // 跳過 <think>continue}if (content.slice(i, i + 8) === '</think>') {isInThinkBlock = false// 將 think 塊包裝在特殊 div 中result += `<div class="think-block">${marked.parse(currentBlock)}</div>`currentBlock = ''i += 7 // 跳過 </think>continue}currentBlock += content[i]}// 處理剩余內容if (currentBlock) {if (isInThinkBlock) {result += `<div class="think-block">${marked.parse(currentBlock)}</div>`} else {result += marked.parse(currentBlock)}}// 凈化處理后的 HTMLconst 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 = `<svg xmlns="http://www.w3.org/2000/svg" class="code-copy-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>`// 添加成功消息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()
}
</script><style scoped lang="scss">
.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;}}
}
</style>

PDFViewer.vue

<template><div class="pdf-view"><div class="pdf-header"><DocumentTextIcon class="icon" /><span class="filename">{{ fileName }}</span></div><div class="pdf-content"><div v-if="isLoading" class="pdf-loading"><div class="loading-spinner"></div><p class="loading-text">正在加載 PDF...</p></div><div class="pdf-container" ref="viewerRef"></div></div></div>
</template><script setup>
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 URLconst url = URL.createObjectURL(props.file)iframe.src = url// 清空容器并添加 iframeviewerRef.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 URLconst url = URL.createObjectURL(file)// 根據當前主題設置 iframe 的背景色if (isDark.value) {iframe.style.backgroundColor = '#1a1a1a'} else {iframe.style.backgroundColor = '#ffffff'}iframe.src = urlreturn { iframe, url }
}// 監聽文件變化
watch(() => props.file, (newFile) => {if (newFile) {// 重新掛載組件if (instance?.url) {URL.revokeObjectURL(instance.url)}try {isLoading.value = trueconst { iframe, url } = createIframe(newFile)// 清空容器并添加 iframeif (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)}
})
</script><style scoped lang="scss">
.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); }
}
</style> 

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}}
}

如果有什么疑問或者建議歡迎評論區留言討論!

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/bicheng/91067.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/91067.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/91067.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Qt小組件 - 8 圖片瀏覽器

一個自制的圖片瀏覽器&#xff0c;如果不想安裝qfluentwidgets&#xff0c; CommandBarView可以使用QWidgetQPushButton替代安裝 qfluentwidgets pip install PySide6-Fluent-Widgets[full]代碼示例 # coding: utf-8 from typing import Unionfrom PySide6.QtCore import Qt, Q…

R study notes[1]

文章目錄introducing to Rreferencesintroducing to R R is an integrated suite involved data handling,storage facility,calculations on arrays,tools for data analysis and so on.running the command R in the terminal of OS can start R software.in R terminal ,to…

由于主庫切換歸檔路徑導致的 Oracle DG 無法同步問題的解決過程

由于主庫切換歸檔路徑導致的 Oracle DG 無法同步問題的解決過程 在上一篇文章中&#xff0c;由于 Oracle 數據庫的歸檔日志空間耗盡導致客戶端無法連接數據庫。在解決的過程中臨時修改了歸檔路徑。后來通過修改參數db_recovery_file_dest_size的值解決了問題。 但該操作導致DG無…

密碼學與加密貨幣:構建去中心化信任的技術基石與未來挑戰

密碼學是加密貨幣的技術基石&#xff0c;兩者通過數學原理構建去中心化信任體系。以下從技術原理、應用場景及未來挑戰三方面展開分析&#xff1a;一、密碼學基礎&#xff1a;加密貨幣的安全基石非對稱加密體系公鑰與私鑰&#xff1a;基于橢圓曲線密碼學&#xff08;ECC&#x…

用于 Web 認證的 抗量子簽名——ML-DSA 草案

1. 引言 本文描述了在 Web Authentication (WebAuthn) 中實現無密碼認證&#xff08;Passwordless authentication&#xff09;的方法&#xff0c;該方法使用模塊格&#xff08;Module-Lattice&#xff09;為基礎的數字簽名標準&#xff08;ML-DSA&#xff09;&#xff0c;即 …

ubuntu18.04解壓大的tar.gz文件失敗

1. 問題描述 我在vmware的虛擬機裝有petalinux環境&#xff0c;需要解壓downloads_2020.2.tar.gz這個大的壓縮包文件&#xff0c;但是總是失敗&#xff0c;而且過程很漫長 tar: downloads/git2/github.com.vim.vim.git/objects/pack/pack-f7f2e2add0c8972a9141b557ef725c38069…

App拉起:喚醒即達,告別繁瑣操作

在移動互聯網進入存量競爭的今天&#xff0c;“讓用戶少點一次、少等一秒”往往意味著20%以上的轉化率差異。openinstall把這套體驗總結成一套可落地的App拉起方案&#xff1a;一套SDK一組鏈接跳轉規則一個可自定義的落地頁&#xff0c;就能把Web→App的整條動線縮成一次點擊。…

開發指南125-HTML DOM事件

1、onload和onunload在頁面或某個元素加載完成后或離開后觸發事件。2、onchange用于在元素的值發生變化時觸發事件。一般用于<input>, <select>, <textarea>等元素3、onfocus 和 onblur激活或失去焦點時觸發4、onmouseover 和 onmouseout鼠標移入或移除時觸發…

使用redis 作為消息隊列時, 如何保證消息的可靠性

使用Redis作為消息隊列時&#xff0c;如何保證消息的可靠性 在分布式系統中&#xff0c;消息隊列扮演著不可或缺的角色&#xff0c;它能夠有效地實現服務間的解耦和異步通信。Redis憑借其出色的性能&#xff0c;常常被用作輕量級的消息隊列。然而&#xff0c;Redis本質上是一個…

CentOS7 安裝和配置教程

CentOS7 安裝和配置教程第一部分&#xff1a;安裝準備1. 下載CentOS 7鏡像2. 創建安裝介質第二部分&#xff1a;安裝步驟1. 在VMeare上安裝CentOS-7-x86_64-Minimal2. 安裝配置3. 安裝過程第三部分&#xff1a;初始配置1. 首次啟動設置2. 網絡配置3. 防火墻配置第四部分&#x…

clock_getres系統調用及示例

39. clock_getres - 獲取時鐘精度 函數介紹 clock_getres系統調用用于獲取指定時鐘的精度&#xff08;分辨率&#xff09;。它返回時鐘能夠表示的最小時間間隔。 函數原型 #include <time.h>int clock_getres(clockid_t clk_id, struct timespec *res);功能 獲取指定時鐘…

MCU+RTOS調試

1. 引言在做項目時&#xff0c;百分之三十的時間寫代碼&#xff0c;還有百分之70的時間用于調試。本期將以Keil為例進行調試章節的講解&#xff0c;目的在于做出一個標準化的調試步驟&#xff0c;方便大家學習如何調試代碼。內容分為基礎調試、中級調試及進階調試三部分&#x…

Redis的數據淘汰策略是什么?有哪些?

1.監測設置了TTL的數據volatile-lru&#xff1a;淘汰最近最少使用的數據volatile-lfu&#xff1a;淘汰最近使用次數最少的數據volatile-ttl&#xff1b;淘汰將要過期的數據volatile-random&#xff1a;隨機淘汰2.監測全庫數據allkeys-lru&#xff1a;淘汰最近最少使用的數據all…

相控陣波束躍度指向誤差Matlab仿真

波束躍度影響&#xff1a;TR芯片移相器位數、陣元數量、校準后陣元初始相位、TR芯片移相器精度、波控計算精度等。用MATLAB進行TR芯片移相器位數、陣元數量對指向誤差進行仿真。 close all; %線陣波束躍度仿真 20250726 %beam displacement % 波束躍度影響&#xff1a;TR芯片移…

板凳-------Mysql cookbook學習 (十二--------6)

MySQL 8 導入二進制文件(trailer.ogv)操作指南 在MySQL中導入二進制文件(如trailer.ogv視頻文件)通常有幾種方法&#xff0c;我將詳細介紹每種方法的操作步驟。 方法一&#xff1a;使用LOAD_FILE函數導入BLOB字段 這是最直接的方法&#xff0c;適合中小型二進制文件。sql - 1. …

昇思學習營-【模型推理和性能優化】學習心得_20250730

一、權重的加載 模型包含兩部分&#xff1a; base model 和 LoRA adapter 其中base model的權重在微調時被凍結&#xff0c; 推理時加載原權重即可&#xff0c;LoRA adapter可通過PeftModel.from_pretrained進行加載。 二、啟動推理 通過model.generate&#xff0c;啟動推理…

[AI8051U入門第十一步]W5500-服務端

學習目標: 1、連接TCP/IP 2、學習W5500作為服務端代碼一、TCP/IP介紹 TCP/IP 協議棧介紹 TCP/IP(Transmission Control Protocol / Internet Protocol)是互聯網通信的核心協議族,定義了數據如何在網絡中進行傳輸和路由。它由多個協議組成,采用分層架構,確保不同設備之間…

C 標準庫 <time.h> 函數詳解

目錄 概述 1 核心數據類型 1.1 time_t 1.2 clock_t 1.3 struct tm 1.4 size_t 2 核心函數 2.1 時間獲取函數 2.2 時間轉換函數 2.3 時間差計算 2.4 時間格式化函數 3 線程安全版本&#xff08;POSIX 擴展&#xff09; 3.1 函數列表 3.2 時間處理完整示例 4 重要…

基于BEKK-GARCH模型的參數估計、最大似然估計以及參數標準誤估計的MATLAB實現

基于BEKK-GARCH模型的參數估計、最大似然估計以及參數標準誤估計的MATLAB實現。BEKK-GARCH模型是一種多變量GARCH模型&#xff0c;用于估計多個時間序列的條件方差和協方差矩陣。 MATLAB實現BEKK-GARCH模型 1. 準備數據 假設你已經有一個時間序列數據矩陣 returns&#xff0c;每…

TDengine 中 TDgpt 用于異常檢測

介紹 TDgpt 內置時序數據異常檢測模型 TDengine 中定義了異常&#xff08;狀態&#xff09;窗口來提供異常檢測服務。異常窗口可以視為一種特殊的事件窗口&#xff08;Event Window&#xff09;&#xff0c;即異常檢測算法確定的連續異常時間序列數據所在的時間窗口。與普通事件…