From 81b71d0d07cdd7421b324d044cf49e284c201667 Mon Sep 17 00:00:00 2001 From: zhangsan <646228430@qq.com> Date: Tue, 29 Jul 2025 11:02:38 +0800 Subject: [PATCH] =?UTF-8?q?7.29=20=E4=BF=AE=E6=94=B9=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/edu/whut/api/IRAGService.java | 2 +- .../edu/whut/trigger/http/RAGController.java | 35 +- docs/tag/v1.0/log/log_info.log | 63 +++ docs/tag/v1.0/nginx/html/css/upload.css | 52 +++ docs/tag/v1.0/nginx/html/js/index.js | 190 +++++---- docs/tag/v1.0/nginx/html/js/upload.js | 383 ++++++++++++++++++ docs/tag/v1.0/nginx/html/upload.html | 290 ++++++------- 7 files changed, 791 insertions(+), 224 deletions(-) create mode 100644 docs/tag/v1.0/nginx/html/css/upload.css create mode 100644 docs/tag/v1.0/nginx/html/js/upload.js diff --git a/ai-rag-knowledge-api/src/main/java/edu/whut/api/IRAGService.java b/ai-rag-knowledge-api/src/main/java/edu/whut/api/IRAGService.java index 981cb36..25c477f 100644 --- a/ai-rag-knowledge-api/src/main/java/edu/whut/api/IRAGService.java +++ b/ai-rag-knowledge-api/src/main/java/edu/whut/api/IRAGService.java @@ -9,7 +9,7 @@ public interface IRAGService { Response> queryRagTagList(); - Response uploadFile(String ragTag, List files); + Response uploadFile(String ragTag, List files, List filePaths); Response analyzeGitRepository(String repoUrl, String userName, String token) throws Exception; diff --git a/ai-rag-knowledge-trigger/src/main/java/edu/whut/trigger/http/RAGController.java b/ai-rag-knowledge-trigger/src/main/java/edu/whut/trigger/http/RAGController.java index 76f608e..fa0ba51 100644 --- a/ai-rag-knowledge-trigger/src/main/java/edu/whut/trigger/http/RAGController.java +++ b/ai-rag-knowledge-trigger/src/main/java/edu/whut/trigger/http/RAGController.java @@ -80,18 +80,42 @@ public class RAGController implements IRAGService { @Override public Response uploadFile( @RequestParam("ragTag") String ragTag, - @RequestParam("file") List files) { + @RequestParam("file") List files, + @RequestParam(value = "filePath", required = false) List filePaths ) { + log.info("上传知识库开始:{}", ragTag); - for (MultipartFile file : files) { + + // 使用带索引的 for 循环,保证与 filePath 一一对应 + for (int i = 0; i < files.size(); i++) { + MultipartFile file = files.get(i); + + // ===== 新增:相对路径(与前端传入顺序一致;缺失时回退到原始文件名)===== + final String relPath = + (filePaths != null && i < filePaths.size() && filePaths.get(i) != null && !filePaths.get(i).isBlank()) + ? filePaths.get(i) + : (file.getOriginalFilename() != null ? file.getOriginalFilename() : ""); + + // 可选:调试日志 + log.debug("接收文件:{},相对路径:{}", file.getOriginalFilename(), relPath); + // 读取上传文件,提取文档内容 TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource()); List documents = documentReader.get(); + // 对文档进行 Token 拆分 List documentSplitterList = tokenTextSplitter.apply(documents); // 为原文档和拆分文档设置 ragTag 元数据 - documents.forEach(doc -> doc.getMetadata().put("knowledge", ragTag)); - documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", ragTag)); + documents.forEach(doc -> { + doc.getMetadata().put("knowledge", ragTag); + // ===== 新增:写入相对路径 ===== + doc.getMetadata().put("path", relPath); + }); + documentSplitterList.forEach(doc -> { + doc.getMetadata().put("knowledge", ragTag); + // ===== 新增:写入相对路径 ===== + doc.getMetadata().put("path", relPath); + }); // 存储拆分后的文档到 pgVectorStore pgVectorStore.accept(documentSplitterList); @@ -102,10 +126,13 @@ public class RAGController implements IRAGService { elements.add(ragTag); } } + log.info("上传知识库完成:{}", ragTag); return Response.builder().code("0000").info("调用成功").build(); } + + /** * 克隆并分析 Git 仓库: * - 克隆指定仓库到本地 diff --git a/docs/tag/v1.0/log/log_info.log b/docs/tag/v1.0/log/log_info.log index ac4453a..4cae312 100644 --- a/docs/tag/v1.0/log/log_info.log +++ b/docs/tag/v1.0/log/log_info.log @@ -164,3 +164,66 @@ 25-07-29.09:53:05.853 [http-nio-8095-exec-5] INFO RAGController - 上传知识库开始:草稿1 25-07-29.09:53:06.647 [http-nio-8095-exec-5] INFO RAGController - 上传知识库完成:草稿1 25-07-29.09:53:30.890 [http-nio-8095-exec-7] INFO OllamaController - generate_stream_rag called! +25-07-29.10:26:15.821 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown initiated... +25-07-29.10:26:15.826 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown completed. +25-07-29.10:27:07.258 [main ] INFO Application - Starting Application v1.0 using Java 17.0.2 with PID 1 (/app.jar started by root in /) +25-07-29.10:27:07.263 [main ] INFO Application - The following 1 profile is active: "dev" +25-07-29.10:27:08.167 [main ] INFO RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +25-07-29.10:27:08.171 [main ] INFO RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +25-07-29.10:27:08.202 [main ] INFO RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 14 ms. Found 0 Redis repository interfaces. +25-07-29.10:27:08.986 [main ] INFO TomcatWebServer - Tomcat initialized with port 8095 (http) +25-07-29.10:27:08.998 [main ] INFO Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8095"] +25-07-29.10:27:09.000 [main ] INFO StandardService - Starting service [Tomcat] +25-07-29.10:27:09.000 [main ] INFO StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.19] +25-07-29.10:27:09.047 [main ] INFO [/] - Initializing Spring embedded WebApplicationContext +25-07-29.10:27:09.048 [main ] INFO ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1672 ms +25-07-29.10:27:09.446 [main ] INFO HikariDataSource - HikariCP - Starting... +25-07-29.10:27:09.630 [main ] INFO HikariPool - HikariCP - Added connection org.postgresql.jdbc.PgConnection@374b6e33 +25-07-29.10:27:09.632 [main ] INFO HikariDataSource - HikariCP - Start completed. +25-07-29.10:27:10.270 [main ] INFO Version - Redisson 3.44.0 +25-07-29.10:27:10.505 [redisson-netty-1-4] INFO ConnectionsHolder - 1 connections initialized for 192.168.10.218/192.168.10.218:26379 +25-07-29.10:27:10.546 [redisson-netty-1-13] INFO ConnectionsHolder - 5 connections initialized for 192.168.10.218/192.168.10.218:26379 +25-07-29.10:27:11.294 [main ] INFO Http11NioProtocol - Starting ProtocolHandler ["http-nio-8095"] +25-07-29.10:27:11.308 [main ] INFO TomcatWebServer - Tomcat started on port 8095 (http) with context path '' +25-07-29.10:27:11.324 [main ] INFO Application - Started Application in 4.858 seconds (process running for 5.691) +25-07-29.10:27:27.925 [http-nio-8095-exec-1] INFO [/] - Initializing Spring DispatcherServlet 'dispatcherServlet' +25-07-29.10:27:27.925 [http-nio-8095-exec-1] INFO DispatcherServlet - Initializing Servlet 'dispatcherServlet' +25-07-29.10:27:27.927 [http-nio-8095-exec-1] INFO DispatcherServlet - Completed initialization in 1 ms +25-07-29.10:27:27.977 [http-nio-8095-exec-1] INFO RAGController - 上传知识库开始:测试01 +25-07-29.10:27:28.578 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 2 chunks. +25-07-29.10:27:29.993 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 15 chunks. +25-07-29.10:27:43.442 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 6 chunks. +25-07-29.10:27:48.303 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 13 chunks. +25-07-29.10:27:59.867 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 5 chunks. +25-07-29.10:28:03.851 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 18 chunks. +25-07-29.10:28:20.184 [http-nio-8095-exec-1] INFO TextSplitter - Splitting up document into 2 chunks. +25-07-29.10:28:22.057 [http-nio-8095-exec-1] INFO RAGController - 上传知识库完成:测试01 +25-07-29.10:37:27.151 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown initiated... +25-07-29.10:37:27.153 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown completed. +25-07-29.10:37:37.830 [main ] INFO Application - Starting Application v1.0 using Java 17.0.2 with PID 1 (/app.jar started by root in /) +25-07-29.10:37:37.835 [main ] INFO Application - The following 1 profile is active: "dev" +25-07-29.10:37:38.660 [main ] INFO RepositoryConfigurationDelegate - Multiple Spring Data modules found, entering strict repository configuration mode +25-07-29.10:37:38.662 [main ] INFO RepositoryConfigurationDelegate - Bootstrapping Spring Data Redis repositories in DEFAULT mode. +25-07-29.10:37:38.689 [main ] INFO RepositoryConfigurationDelegate - Finished Spring Data repository scanning in 11 ms. Found 0 Redis repository interfaces. +25-07-29.10:37:39.369 [main ] INFO TomcatWebServer - Tomcat initialized with port 8095 (http) +25-07-29.10:37:39.378 [main ] INFO Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8095"] +25-07-29.10:37:39.380 [main ] INFO StandardService - Starting service [Tomcat] +25-07-29.10:37:39.380 [main ] INFO StandardEngine - Starting Servlet engine: [Apache Tomcat/10.1.19] +25-07-29.10:37:39.416 [main ] INFO [/] - Initializing Spring embedded WebApplicationContext +25-07-29.10:37:39.416 [main ] INFO ServletWebServerApplicationContext - Root WebApplicationContext: initialization completed in 1477 ms +25-07-29.10:37:39.774 [main ] INFO HikariDataSource - HikariCP - Starting... +25-07-29.10:37:39.967 [main ] INFO HikariPool - HikariCP - Added connection org.postgresql.jdbc.PgConnection@cdbe995 +25-07-29.10:37:39.969 [main ] INFO HikariDataSource - HikariCP - Start completed. +25-07-29.10:37:42.629 [main ] INFO Version - Redisson 3.44.0 +25-07-29.10:37:42.905 [redisson-netty-1-4] INFO ConnectionsHolder - 1 connections initialized for 192.168.10.218/192.168.10.218:26379 +25-07-29.10:37:42.940 [redisson-netty-1-13] INFO ConnectionsHolder - 5 connections initialized for 192.168.10.218/192.168.10.218:26379 +25-07-29.10:37:43.452 [main ] INFO Http11NioProtocol - Starting ProtocolHandler ["http-nio-8095"] +25-07-29.10:37:43.460 [main ] INFO TomcatWebServer - Tomcat started on port 8095 (http) with context path '' +25-07-29.10:37:43.468 [main ] INFO Application - Started Application in 6.441 seconds (process running for 7.065) +25-07-29.10:37:55.291 [http-nio-8095-exec-1] INFO [/] - Initializing Spring DispatcherServlet 'dispatcherServlet' +25-07-29.10:37:55.291 [http-nio-8095-exec-1] INFO DispatcherServlet - Initializing Servlet 'dispatcherServlet' +25-07-29.10:37:55.292 [http-nio-8095-exec-1] INFO DispatcherServlet - Completed initialization in 1 ms +25-07-29.10:37:55.337 [http-nio-8095-exec-1] INFO RAGController - 上传知识库开始:测试02 +25-07-29.10:37:55.992 [http-nio-8095-exec-1] INFO RAGController - 上传知识库完成:测试02 +25-07-29.10:49:25.780 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown initiated... +25-07-29.10:49:25.783 [SpringApplicationShutdownHook] INFO HikariDataSource - HikariCP - Shutdown completed. diff --git a/docs/tag/v1.0/nginx/html/css/upload.css b/docs/tag/v1.0/nginx/html/css/upload.css new file mode 100644 index 0000000..f90e0be --- /dev/null +++ b/docs/tag/v1.0/nginx/html/css/upload.css @@ -0,0 +1,52 @@ +/* 旋转动画(loading 图标) */ +.loader { + animation: spin 1s linear infinite; +} +@keyframes spin { + 0% { transform: rotate(0deg) } + 100% { transform: rotate(360deg) } +} + +/* 拖拽高亮:与 Tailwind 共存,覆盖边框与背景 */ +.drag-zone.drag-active { + border-color: #2563eb; /* blue-600 */ + background-color: #eff6ff; /* blue-50 */ + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +/* 文件列表样式微调 */ +#fileList li { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.75rem; + padding: 0.75rem 1rem; + align-items: center; +} +#fileList .file-name { + font-size: 0.95rem; + color: #111827; /* gray-900 */ + word-break: break-all; +} +#fileList .file-meta { + font-size: 0.78rem; + color: #6b7280; /* gray-500 */ +} +#fileList .remove-btn { + color: #ef4444; /* red-500 */ +} +#fileList .remove-btn:hover { + color: #b91c1c; /* red-700 */ +} + +/* 隐藏但保留可访问性的类(配合HTML中的 sr-only)*/ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0,0,0,0) !important; + white-space: nowrap !important; + border: 0 !important; +} diff --git a/docs/tag/v1.0/nginx/html/js/index.js b/docs/tag/v1.0/nginx/html/js/index.js index 654ee49..e0ce399 100644 --- a/docs/tag/v1.0/nginx/html/js/index.js +++ b/docs/tag/v1.0/nginx/html/js/index.js @@ -1,3 +1,5 @@ +// ===== index.js (modified to auto-refresh RAG list after upload) ===== + const chatArea = document.getElementById('chatArea'); const messageInput = document.getElementById('messageInput'); const submitBtn = document.getElementById('submitBtn'); @@ -9,37 +11,82 @@ const sidebar = document.getElementById('sidebar'); let currentEventSource = null; let currentChatId = null; -// 获取知识库列表 -document.addEventListener('DOMContentLoaded', function() { - // 获取知识库列表 - const loadRagOptions = () => { - const ragSelect = document.getElementById('ragSelect'); +/** ------------------------- + * RAG 列表加载(提升为顶层可复用) + * 支持可选预选值 preselectTag + * ------------------------- */ +function loadRagOptions(preselectTag) { + const ragSelect = document.getElementById('ragSelect'); - fetch('/api/v1/rag/query_rag_tag_list') - .then(response => response.json()) - .then(data => { - if (data.code === '0000' && data.data) { - // 清空现有选项(保留第一个默认选项) - while (ragSelect.options.length > 1) { - ragSelect.remove(1); - } - - // 添加新选项 - data.data.forEach(tag => { - const option = new Option(`Rag:${tag}`, tag); - ragSelect.add(option); - }); + return fetch('/api/v1/rag/query_rag_tag_list') + .then(response => response.json()) + .then(data => { + if (data.code === '0000' && data.data) { + // 清空现有选项(保留第一个默认选项) + while (ragSelect.options.length > 1) { + ragSelect.remove(1); } - }) - .catch(error => { - console.error('获取知识库列表失败:', error); - }); - }; + // 添加新选项 + data.data.forEach(tag => { + const option = new Option(`Rag:${tag}`, tag); + ragSelect.add(option); + }); - // 初始化加载 + // 可选:预选刚创建的 ragTag + if (preselectTag) { + const exists = Array.from(ragSelect.options).some(o => o.value === preselectTag); + if (!exists) { + ragSelect.add(new Option(`Rag:${preselectTag}`, preselectTag)); + } + ragSelect.value = preselectTag; + } + } + }) + .catch(error => { + console.error('获取知识库列表失败:', error); + }); +} + +// 获取知识库列表(首屏) +document.addEventListener('DOMContentLoaded', function () { loadRagOptions(); }); +/** ------------------------- + * 接收来自 upload.html 的刷新通知 + * 方式 A:postMessage + * 方式 B:BroadcastChannel + * 兜底:页面从后台切回前台自动刷新 + * ------------------------- */ +// A. postMessage(当 upload.html 有 opener 时) +window.addEventListener('message', (event) => { + if (event.origin !== window.location.origin) return; + const msg = event.data; + if (msg && msg.type === 'RAG_LIST_REFRESH') { + loadRagOptions(msg.ragTag); + } +}); + +// B. BroadcastChannel(不依赖 opener,现代浏览器有效) +if ('BroadcastChannel' in window) { + const bc = new BroadcastChannel('rag-updates'); + bc.addEventListener('message', (event) => { + const msg = event.data; + if (msg && msg.type === 'rag:updated') { + loadRagOptions(msg.ragTag); + } + }); +} + +// C. 兜底:页面重新获得焦点/可见时刷新一次 +window.addEventListener('focus', () => loadRagOptions()); +document.addEventListener('visibilitychange', () => { + if (!document.hidden) loadRagOptions(); +}); + +/** ------------------------- + * 聊天逻辑 + * ------------------------- */ function createNewChat() { const chatId = Date.now().toString(); currentChatId = chatId; @@ -55,21 +102,20 @@ function createNewChat() { function deleteChat(chatId) { if (confirm('确定要删除这个聊天记录吗?')) { - localStorage.removeItem(`chat_${chatId}`); // Remove the chat from localStorage - if (currentChatId === chatId) { // If the current chat is being deleted - createNewChat(); // Create a new chat + localStorage.removeItem(`chat_${chatId}`); + if (currentChatId === chatId) { + createNewChat(); } - updateChatList(); // Update the chat list to reflect changes + updateChatList(); } } function updateChatList() { chatList.innerHTML = ''; - const chats = Object.keys(localStorage) - .filter(key => key.startsWith('chat_')); + const chats = Object.keys(localStorage).filter(key => key.startsWith('chat_')); const currentChatIndex = chats.findIndex(key => key.split('_')[1] === currentChatId); - if (currentChatIndex!== -1) { + if (currentChatIndex !== -1) { const currentChat = chats[currentChatIndex]; chats.splice(currentChatIndex, 1); chats.unshift(currentChat); @@ -89,17 +135,17 @@ function updateChatList() { } const li = document.createElement('li'); - li.className = `chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${chatId === currentChatId? 'bg-blue-50' : ''}`; + li.className = `chat-item flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg cursor-pointer transition-colors ${chatId === currentChatId ? 'bg-blue-50' : ''}`; li.innerHTML = ` -
-
${chatData.name}
-
${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}
-
-
- - -
- `; +
+
${chatData.name}
+
${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}
+
+
+ + +
+ `; li.addEventListener('click', (e) => { if (!e.target.closest('.chat-actions')) { loadChat(chatId); @@ -129,19 +175,19 @@ function showChatContextMenu(event, chatId) { menu.style.top = `${buttonRect.bottom + 4}px`; menu.innerHTML = ` -
- - - - 重命名 -
-
- - - - 删除 -
- `; +
+ + + + 重命名 +
+
+ + + + 删除 +
+ `; document.body.appendChild(menu); currentContextMenu = menu; @@ -176,11 +222,11 @@ function loadChat(chatId) { currentChatId = chatId; localStorage.setItem('currentChatId', chatId); clearChatArea(); - const chatData = JSON.parse(localStorage.getItem(`chat_${chatId}`) || { messages: [] }); - chatData.messages.forEach(msg => { + const chatData = JSON.parse(localStorage.getItem(`chat_${chatId}`) || '{ "messages": [] }'); + (chatData.messages || []).forEach(msg => { appendMessage(msg.content, msg.isAssistant, false); }); - updateChatList() + updateChatList(); } function clearChatArea() { @@ -203,7 +249,7 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { copyBtn.onclick = () => { navigator.clipboard.writeText(content).then(() => { copyBtn.textContent = '已复制'; - setTimeout(() => copyBtn.textContent = '复制', 2000); + setTimeout(() => (copyBtn.textContent = '复制'), 2000); }); }; messageDiv.appendChild(copyBtn); @@ -213,33 +259,26 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { // 仅在需要时保存到本地存储 if (saveToStorage && currentChatId) { - // 确保读取和保存完整的数据结构 const chatData = JSON.parse(localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}'); chatData.messages.push({ content, isAssistant }); localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData)); } } - function startEventStream(message) { +function startEventStream(message) { if (currentEventSource) { currentEventSource.close(); } - // 选项值, - // 组装01;/api/v1/ollama/generate_stream?message=Hello&model=deepseek-r1:1.5b - // 组装02;/api/v1/openai/generate_stream?message=Hello&model=gpt-4o + // 组装流式接口 const ragTag = document.getElementById('ragSelect').value; const aiModelSelect = document.getElementById('aiModel'); - const aiModelValue = aiModelSelect.value; // 获取选中的 aiModel 的 value - const aiModelModel = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model'); // 获取选中的 aiModel 的 model 属性 + const aiModelValue = aiModelSelect.value; // openai / ollama + const aiModelModel = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model'); let url; - const base = `/api/v1/${aiModelValue}`; - const params = new URLSearchParams({ - message, - model: aiModelModel - }); + const params = new URLSearchParams({ message, model: aiModelModel }); if (ragTag) { params.append('ragTag', ragTag); url = `${base}/generate_stream_rag?${params.toString()}`; @@ -251,7 +290,7 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { let accumulatedContent = ''; let tempMessageDiv = null; - currentEventSource.onmessage = function(event) { + currentEventSource.onmessage = function (event) { try { const data = JSON.parse(event.data); @@ -267,7 +306,7 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { welcomeMessage.style.display = 'none'; } - // 直接更新文本内容(先不解析Markdown) + // 直接更新文本内容(先不解析 Markdown) tempMessageDiv.textContent = accumulatedContent; chatArea.scrollTop = chatArea.scrollHeight; } @@ -286,14 +325,13 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { copyBtn.onclick = () => { navigator.clipboard.writeText(finalContent).then(() => { copyBtn.textContent = '已复制'; - setTimeout(() => copyBtn.textContent = '复制', 2000); + setTimeout(() => (copyBtn.textContent = '复制'), 2000); }); }; tempMessageDiv.appendChild(copyBtn); // 保存到本地存储 if (currentChatId) { - // 正确的数据结构应该是对象包含messages数组 const chatData = JSON.parse(localStorage.getItem(`chat_${currentChatId}`) || '{"name": "新聊天", "messages": []}'); chatData.messages.push({ content: finalContent, isAssistant: true }); localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData)); @@ -304,7 +342,7 @@ function appendMessage(content, isAssistant = false, saveToStorage = true) { } }; - currentEventSource.onerror = function(error) { + currentEventSource.onerror = function (error) { console.error('EventSource error:', error); currentEventSource.close(); }; diff --git a/docs/tag/v1.0/nginx/html/js/upload.js b/docs/tag/v1.0/nginx/html/js/upload.js new file mode 100644 index 0000000..4549850 --- /dev/null +++ b/docs/tag/v1.0/nginx/html/js/upload.js @@ -0,0 +1,383 @@ +// ====== 配置 ====== +const ENDPOINT = "/api/v1/rag/file/upload"; +const ALLOWED_EXTS = [".pdf", ".csv", ".txt", ".md", ".sql", ".java"]; +const MAX_SCAN_DEPTH = 20; // 防止极端深层目录卡住(可自行调整) + +// ====== 工具函数 ====== +const $ = (sel) => document.querySelector(sel); + +function formatBytes(bytes) { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +function extOfName(name) { + return (name.match(/\.[^.]+$/) || [""])[0].toLowerCase(); +} +function isAllowedName(name) { + return ALLOWED_EXTS.includes(extOfName(name)); +} + +// 用于渲染与去重时的“相对路径”,优先使用浏览器提供的相对路径 +function displayPath(file) { + return file.webkitRelativePath || file._relativePath || file.name; +} + +// 以路径+大小+mtime 作为去重 key(避免不同子目录下同名文件冲突) +function uniqueKey(file) { + return `${displayPath(file)}__${file.size}__${file.lastModified}`; +} + +// 将 FileList + 新增 files 合并为去重后的 DataTransfer.files +function mergeFiles(existingList, newFiles) { + const map = new Map(); + Array.from(existingList || []).forEach((f) => map.set(uniqueKey(f), f)); + Array.from(newFiles || []).forEach((f) => map.set(uniqueKey(f), f)); + + const dt = new DataTransfer(); + for (const f of map.values()) dt.items.add(f); + return dt.files; +} + +// ====== DOM 引用 ====== +const form = $("#uploadForm"); +const dropZone = $("#dropZone"); +const fileInput = $("#fileInput"); +const dirInput = $("#dirInput"); +const chooseDirBtn = $("#chooseDirBtn"); +const fileListWrap = $("#fileList"); +const fileListUl = $("#fileList ul"); +const clearAllBtn = $("#clearAllBtn"); +const loadingOverlay = $("#loadingOverlay"); +const submitBtn = $("#submitBtn"); +const progressWrap = $("#progressWrap"); +const progressBar = $("#progressBar"); +const progressText = $("#progressText"); + +// ====== 阻止默认拖拽打开新窗口(关键) ====== +["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { + document.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }); +}); + +// ====== 支持:点击选择文件 / 选择目录 ====== +dropZone.addEventListener("click", () => fileInput.click()); +dropZone.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInput.click(); + } +}); +chooseDirBtn?.addEventListener("click", () => dirInput?.click()); + +// ====== 拖拽区交互状态 ====== +dropZone.addEventListener("dragenter", () => dropZone.classList.add("drag-active")); +dropZone.addEventListener("dragover", () => dropZone.classList.add("drag-active")); +dropZone.addEventListener("dragleave", (e) => { + if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove("drag-active"); +}); + +// ====== 拖拽接收(支持文件夹递归) ====== +dropZone.addEventListener("drop", async (e) => { + dropZone.classList.remove("drag-active"); + + const items = e.dataTransfer?.items; + const plainFiles = e.dataTransfer?.files; + + let collected = []; + try { + if (items && items.length) { + collected = await collectFromDataTransferItems(items); + } else if (plainFiles && plainFiles.length) { + collected = Array.from(plainFiles); + } + } catch (err) { + console.warn("解析拖拽内容失败,回退到 files:", err); + collected = Array.from(plainFiles || []); + } + + const { accepted, rejectedCount } = filterAllowed(collected); + if (rejectedCount > 0) { + alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`); + } + + if (accepted.length) { + fileInput.files = mergeFiles(fileInput.files, accepted); + renderFileList(); + } +}); + +// ====== 目录选择(webkitdirectory) ====== +dirInput?.addEventListener("change", () => { + const all = Array.from(dirInput.files || []); // 目录选择会包含 webkitRelativePath + const { accepted, rejectedCount } = filterAllowed(all, (f) => f.webkitRelativePath || f.name); + + if (rejectedCount > 0) { + alert(`已忽略 ${rejectedCount} 个不支持的文件类型。\n支持:${ALLOWED_EXTS.join(" ")}`); + } + if (accepted.length) { + fileInput.files = mergeFiles(fileInput.files, accepted); + renderFileList(); + } + // 允许连续两次选择同一文件夹 + dirInput.value = ""; +}); + +// ====== 文件选择变化(普通文件选择) ====== +fileInput.addEventListener("change", renderFileList); + +clearAllBtn.addEventListener("click", () => { + fileInput.value = ""; + renderFileList(); +}); + +// ====== 渲染文件列表(显示相对路径) ====== +function renderFileList() { + const files = Array.from(fileInput.files || []); + fileListUl.innerHTML = ""; + + if (files.length === 0) { + fileListWrap.classList.add("hidden"); + return; + } + fileListWrap.classList.remove("hidden"); + + files.forEach((file, idx) => { + const li = document.createElement("li"); + + const leftWrap = document.createElement("div"); + const nameEl = document.createElement("div"); + const metaEl = document.createElement("div"); + nameEl.className = "file-name"; + metaEl.className = "file-meta"; + + const rel = displayPath(file); + nameEl.textContent = rel; + metaEl.textContent = `${file.type || "未知类型"} · ${formatBytes(file.size)}`; + + leftWrap.appendChild(nameEl); + leftWrap.appendChild(metaEl); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "remove-btn text-sm"; + removeBtn.textContent = "删除"; + removeBtn.addEventListener("click", () => removeFile(idx)); + + li.appendChild(leftWrap); + li.appendChild(removeBtn); + fileListUl.appendChild(li); + }); +} + +// ====== 删除单个文件 ====== +function removeFile(index) { + const files = Array.from(fileInput.files); + files.splice(index, 1); + + const dt = new DataTransfer(); + files.forEach((f) => dt.items.add(f)); + fileInput.files = dt.files; + + renderFileList(); +} + +// ====== 过滤后缀白名单 ====== +function filterAllowed(files, pathGetter) { + let rejectedCount = 0; + const accepted = []; + + for (const f of files) { + const p = pathGetter ? pathGetter(f) : displayPath(f) || f.name; + if (isAllowedName(p)) { + accepted.push(f); + } else { + rejectedCount++; + } + } + return { accepted, rejectedCount }; +} + +// ====== 拖拽目录解析(两套方案 + 回退) ====== +async function collectFromDataTransferItems(items) { + const supportsFSH = + typeof DataTransferItem !== "undefined" && + DataTransferItem.prototype && + "getAsFileSystemHandle" in DataTransferItem.prototype; + + const collected = []; + + // 方案 1:File System Access API(Chromium 新方案) + if (supportsFSH) { + const jobs = []; + for (const item of items) { + jobs.push(handleItemWithFSH(item, collected)); + } + await Promise.all(jobs); + return collected; + } + + // 方案 2:webkitGetAsEntry(Safari/老版 Chromium) + const supportsWebkitEntry = + items[0] && typeof items[0].webkitGetAsEntry === "function"; + if (supportsWebkitEntry) { + const jobs = []; + for (const item of items) { + const entry = item.webkitGetAsEntry && item.webkitGetAsEntry(); + if (!entry) continue; + jobs.push(walkWebkitEntry(entry, collected, "", 0)); + } + await Promise.all(jobs); + return collected; + } + + // 回退:只能拿到平铺的 files,无法识别目录 + for (const item of items) { + const f = item.getAsFile && item.getAsFile(); + if (f) collected.push(f); + } + return collected; +} + +// ------ 方案 1:FS Access API ------ +async function handleItemWithFSH(item, out) { + const handle = await item.getAsFileSystemHandle(); + if (!handle) return; + + if (handle.kind === "file") { + const file = await handle.getFile(); + // 无法直接获取相对路径,使用文件名作为相对路径 + file._relativePath = file.name; + out.push(file); + } else if (handle.kind === "directory") { + await walkDirectoryHandle(handle, out, `${handle.name}/`, 0); + } +} + +async function walkDirectoryHandle(dirHandle, out, prefix = "", depth = 0) { + if (depth > MAX_SCAN_DEPTH) return; + for await (const [name, handle] of dirHandle.entries()) { + if (handle.kind === "file") { + const file = await handle.getFile(); + file._relativePath = `${prefix}${name}`; + out.push(file); + } else if (handle.kind === "directory") { + await walkDirectoryHandle(handle, out, `${prefix}${name}/`, depth + 1); + } + } +} + +// ------ 方案 2:webkitGetAsEntry ------ +function readEntriesAsync(dirReader) { + return new Promise((resolve, reject) => { + dirReader.readEntries(resolve, reject); + }); +} + +async function walkWebkitEntry(entry, out, prefix = "", depth = 0) { + if (depth > MAX_SCAN_DEPTH) return; + + if (entry.isFile) { + await new Promise((resolve) => { + entry.file((file) => { + file._relativePath = `${prefix}${entry.name}`; + resolve(out.push(file)); + }, () => resolve()); + }); + } else if (entry.isDirectory) { + const dirReader = entry.createReader(); + let entries = []; + do { + entries = await readEntriesAsync(dirReader); + for (const ent of entries) { + await walkWebkitEntry(ent, out, `${prefix}${entry.name}/`, depth + 1); + } + } while (entries.length > 0); + } +} + +// ====== 表单提交 ====== +form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const title = $("#title").value.trim(); + const files = Array.from(fileInput.files || []); + + if (!title) { + alert("请先填写知识库名称"); + return; + } + if (files.length === 0) { + alert("请先选择至少一个文件或文件夹"); + return; + } + + const fd = new FormData(); + fd.append("ragTag", title); + + // 将文件按顺序追加 + // 可选:若你想把“相对路径”也传给后端,请同时 append 一个 filePath(顺序与 file 对应) + files.forEach((f) => { + fd.append("file", f); + fd.append("filePath", displayPath(f)); // 需要后端额外接收 + }); + + // UI 状态 + loadingOverlay.classList.remove("hidden"); + progressWrap.classList.remove("hidden"); + submitBtn.disabled = true; + + try { + const res = await axios.post(ENDPOINT, fd, { + onUploadProgress: (evt) => { + if (!evt.total) return; + const pct = Math.round((evt.loaded / evt.total) * 100); + progressBar.style.width = `${pct}%`; + progressText.textContent = `${pct}%`; + }, + }); + + if (res?.data?.code === "0000") { + // === 通知 index.html 刷新 Rag 列表(两种方式都发,保证健壮性) === + const ragTagJustCreated = title; // 你提交的 ragTag 标题 + + try { + // 1) postMessage:同源且 opener 存在时生效 + if (window.opener && !window.opener.closed) { + window.opener.postMessage( + { type: "RAG_LIST_REFRESH", ragTag: ragTagJustCreated }, + window.location.origin + ); + } + } catch (_) {} + + try { + // 2) BroadcastChannel:同源标签页广播 + if ("BroadcastChannel" in window) { + const bc = new BroadcastChannel("rag-updates"); + bc.postMessage({ type: "rag:updated", ragTag: ragTagJustCreated }); + bc.close(); + } + } catch (_) {} + + alert("上传成功,窗口即将关闭"); + setTimeout(() => window.close(), 500); + } else { + throw new Error(res?.data?.info || "上传失败"); + } + } catch (err) { + alert(err?.message || "上传过程中出现错误"); + } finally { + loadingOverlay.classList.add("hidden"); + submitBtn.disabled = false; + progressWrap.classList.add("hidden"); + progressBar.style.width = "0%"; + progressText.textContent = "0%"; + renderFileList(); + } +}); diff --git a/docs/tag/v1.0/nginx/html/upload.html b/docs/tag/v1.0/nginx/html/upload.html index 0cb911f..4f9c4f4 100644 --- a/docs/tag/v1.0/nginx/html/upload.html +++ b/docs/tag/v1.0/nginx/html/upload.html @@ -1,158 +1,162 @@ - - + + 文件上传 - + + - + + + + + + + + - - -
- - + +
+
+ +
+

添加知识

+

支持拖拽或点击上传多个文件

-

添加知识

-
- -
- - + + + +
+ + +
+ + +
+ + +
+ + + +
+ +
+ 将文件或文件夹拖到此处 + 点击选择文件 +
+
+ 支持文件后缀:.pdf .csv .txt .md .sql .java(可多选) +
+
+
+ + +
+ + + +
+
+ + + + + + + + +
+ +
+
- -
- -
- - + + - - -
-
    -
    - - -
    - -
    - +
    - -