7.29 修改前端页面展示

This commit is contained in:
zhangsan 2025-07-29 11:02:38 +08:00
parent 1ea68cb74f
commit 81b71d0d07
7 changed files with 791 additions and 224 deletions

View File

@ -9,7 +9,7 @@ public interface IRAGService {
Response<List<String>> queryRagTagList();
Response<String> uploadFile(String ragTag, List<MultipartFile> files);
Response<String> uploadFile(String ragTag, List<MultipartFile> files, List<String> filePaths);
Response<String> analyzeGitRepository(String repoUrl, String userName, String token) throws Exception;

View File

@ -80,18 +80,42 @@ public class RAGController implements IRAGService {
@Override
public Response<String> uploadFile(
@RequestParam("ragTag") String ragTag,
@RequestParam("file") List<MultipartFile> files) {
@RequestParam("file") List<MultipartFile> files,
@RequestParam(value = "filePath", required = false) List<String> 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<Document> documents = documentReader.get();
// 对文档进行 Token 拆分
List<Document> 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.<String>builder().code("0000").info("调用成功").build();
}
/**
* 克隆并分析 Git 仓库
* - 克隆指定仓库到本地

View File

@ -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.

View File

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

View File

@ -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 的刷新通知
* 方式 ApostMessage
* 方式 BBroadcastChannel
* 兜底页面从后台切回前台自动刷新
* ------------------------- */
// 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 = `
<div class="flex-1">
<div class="text-sm font-medium">${chatData.name}</div>
<div class="text-xs text-gray-400">${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}</div>
</div>
<div class="chat-actions flex items-center gap-1 opacity-0 transition-opacity duration-200">
<button class="p-1 hover:bg-gray-200 rounded text-gray-500" onclick="renameChat('${chatId}')">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500" onclick="deleteChat('${chatId}')">删除</button>
</div>
`;
<div class="flex-1">
<div class="text-sm font-medium">${chatData.name}</div>
<div class="text-xs text-gray-400">${new Date(parseInt(chatId)).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}</div>
</div>
<div class="chat-actions flex items-center gap-1 opacity-0 transition-opacity duration-200">
<button class="p-1 hover:bg-gray-200 rounded text-gray-500" onclick="renameChat('${chatId}')">重命名</button>
<button class="p-1 hover:bg-red-200 rounded text-red-500" onclick="deleteChat('${chatId}')">删除</button>
</div>
`;
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 = `
<div class="context-menu-item" onclick="renameChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
重命名
</div>
<div class="context-menu-item text-red-500" onclick="deleteChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
删除
</div>
`;
<div class="context-menu-item" onclick="renameChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
重命名
</div>
<div class="context-menu-item text-red-500" onclick="deleteChat('${chatId}')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
删除
</div>
`;
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();
};

View File

@ -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 = [];
// 方案 1File System Access APIChromium 新方案)
if (supportsFSH) {
const jobs = [];
for (const item of items) {
jobs.push(handleItemWithFSH(item, collected));
}
await Promise.all(jobs);
return collected;
}
// 方案 2webkitGetAsEntrySafari/老版 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;
}
// ------ 方案 1FS 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);
}
}
}
// ------ 方案 2webkitGetAsEntry ------
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();
}
});

View File

@ -1,158 +1,162 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文件上传</title>
<script src="js/axios.min.js"></script>
<!-- Tailwind CDN可保留也可改为自己构建的CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* 加载动画 */
.loader {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<!-- 自定义样式 -->
<link rel="stylesheet" href="css/upload.css" />
<!-- AxiosCDN 版本;也可改回你本地的 js/axios.min.js -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js" defer></script>
<!-- 交互逻辑 -->
<script src="js/upload.js" defer></script>
</head>
<body class="flex justify-center items-center min-h-screen bg-gray-100">
<!-- 上传文件模态框 -->
<div class="bg-white p-6 rounded-lg shadow-lg w-96 relative">
<!-- 加载遮罩层 -->
<div id="loadingOverlay" class="hidden absolute inset-0 bg-white bg-opacity-90 flex flex-col items-center justify-center rounded-lg">
<div class="loader mb-4">
<svg class="h-8 w-8 text-blue-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2V6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M12 18V22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.93 4.93L7.76 7.76" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16.24 16.24L19.07 19.07" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M2 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M18 12H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.93 19.07L7.76 16.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16.24 7.76L19.07 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<p class="text-gray-600">文件上传中,请稍候...</p>
</div>
<body class="min-h-screen min-h-[100dvh] bg-gradient-to-br from-gray-50 to-gray-100 grid place-items-center">
<div class="container mx-auto px-4 py-10">
<div class="mx-auto max-w-lg relative">
<!-- 卡片 -->
<div class="bg-white/90 backdrop-blur p-6 rounded-2xl shadow-xl ring-1 ring-gray-100">
<h2 class="text-2xl font-semibold text-center mb-2">添加知识</h2>
<p class="text-center text-gray-500 mb-6">支持拖拽或点击上传多个文件</p>
<h2 class="text-xl font-semibold text-center mb-4">添加知识</h2>
<form id="uploadForm" enctype="multipart/form-data">
<!-- 知识标题输入 -->
<div class="mb-4">
<label for="title" class="block text-sm font-medium text-gray-700">知识标题</label>
<input type="text" id="title" name="title" class="mt-1 block w-full p-2 border border-gray-300 rounded-md" placeholder="输入知识标题" required />
<!-- 表单 -->
<form id="uploadForm" enctype="multipart/form-data" class="space-y-5">
<!-- 标题 -->
<div>
<label for="title" class="block text-sm font-medium text-gray-700"
>知识库名称</label>
<input
type="text"
id="title"
name="title"
class="mt-1 block w-full rounded-lg border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入知识库名称"
required
aria-describedby="titleHelp"
/>
</div>
<!-- 拖拽/点击区域 -->
<div>
<label class="block text-sm font-medium text-gray-700">上传文件</label>
<div
id="dropZone"
class="mt-2 drag-zone group border-2 border-dashed border-gray-300 rounded-xl p-6 text-center text-gray-500 transition-all"
role="button"
tabindex="0"
aria-label="将文件或文件夹拖拽到此处,或点击选择文件"
>
<!-- 隐藏但可访问的原生文件输入(选择文件) -->
<input
type="file"
id="fileInput"
class="sr-only"
accept=".pdf,.csv,.txt,.md,.sql,.java"
multiple
/>
<div class="flex flex-col items-center justify-center gap-2 pointer-events-none">
<svg
class="h-10 w-10 opacity-80"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M12 16V4m0 0l-4 4m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div class="text-base">
<strong>文件或文件夹</strong>拖到此处 <span class="text-gray-400"></span>
<span class="text-blue-600 underline decoration-dotted">点击选择文件</span>
</div>
<div class="text-xs text-gray-400">
支持文件后缀:.pdf .csv .txt .md .sql .java可多选
</div>
</div>
</div>
<!-- 选择文件夹按钮 + 隐藏目录输入 -->
<div class="mt-2 text-center">
<button type="button" id="chooseDirBtn" class="text-sm text-blue-600 hover:text-blue-700 underline">
或选择一个文件夹上传
</button>
<!-- 选择目录Chrome/Edge/Safari 支持Firefox 暂不支持) -->
<input type="file" id="dirInput" class="sr-only" webkitdirectory directory multiple />
</div>
</div>
<!-- 文件列表 -->
<div id="fileList" class="hidden">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">待上传文件</span>
<button
type="button"
id="clearAllBtn"
class="text-sm text-gray-500 hover:text-red-600"
>
清空
</button>
</div>
<ul class="list-none divide-y divide-gray-100 rounded-lg border border-gray-100" aria-live="polite"></ul>
</div>
<!-- 进度条 -->
<div id="progressWrap" class="hidden">
<div class="flex justify-between text-xs text-gray-500 mb-1">
<span>上传进度</span><span id="progressText">0%</span>
</div>
<div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div id="progressBar" class="h-full bg-blue-600 transition-all" style="width: 0%"></div>
</div>
</div>
<!-- 提交 -->
<div class="flex justify-center pt-2">
<button
type="submit"
class="bg-blue-600 text-white py-2 px-5 rounded-lg hover:bg-blue-700 active:scale-[0.99] disabled:opacity-60"
id="submitBtn"
>
提交
</button>
</div>
</form>
</div>
<!-- 上传文件区域 -->
<div class="mb-4">
<label for="file" class="block text-sm font-medium text-gray-700">上传文件</label>
<div class="mt-2 border-dashed border-2 border-gray-300 p-4 text-center text-gray-500">
<input type="file" id="file" name="file" accept=".pdf,.csv,.txt,.md,.sql,.java" class="hidden" multiple />
<label for="file" class="cursor-pointer">
<div>将文件拖到此处或点击上传</div>
<div class="mt-2 text-sm text-gray-400">支持的文件类型:.pdf, .csv, .txt, .md, .sql, .java</div>
</label>
<!-- 加载遮罩层 -->
<div
id="loadingOverlay"
class="hidden absolute inset-0 bg-white/80 backdrop-blur flex flex-col items-center justify-center rounded-2xl"
aria-hidden="true"
>
<div class="loader mb-3">
<svg class="h-8 w-8 text-blue-600" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 2V6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M12 18V22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.93 4.93L7.76 7.76" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16.24 16.24L19.07 19.07" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M2 12H6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M18 12H22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4.93 19.07L7.76 16.24" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M16.24 7.76L19.07 4.93" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<p class="text-gray-600 text-sm">文件上传中,请稍候...</p>
</div>
<!-- 待上传文件列表 -->
<div class="mb-4" id="fileList">
<ul class="list-disc pl-5 text-gray-700"></ul>
</div>
<!-- 提交按钮 -->
<div class="flex justify-center">
<button type="submit" class="bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700">
提交
</button>
</div>
</form>
</div>
</div>
<script>
const fileListElement = document.querySelector('#fileList ul');
// 文件选择变更处理
document.getElementById('file').addEventListener('change', function (e) {
const files = Array.from(e.target.files);
fileListElement.innerHTML = ''; // 清空列表
files.forEach((file, index) => {
const listItem = document.createElement('li');
listItem.className = 'flex justify-between items-center';
listItem.innerHTML = `
<span>${file.name}</span>
<button type="button" class="text-red-500 hover:text-red-700" onclick="removeFile(${index})">删除</button>
`;
fileListElement.appendChild(listItem);
});
});
// 移除文件
function removeFile(index) {
const input = document.getElementById('file');
let files = Array.from(input.files);
files.splice(index, 1);
// 创建一个新的DataTransfer对象
const dataTransfer = new DataTransfer();
files.forEach(file => dataTransfer.items.add(file));
// 更新文件输入对象的文件列表
input.files = dataTransfer.files;
// 更新文件列表UI
const fileListItems = fileListElement.children;
fileListItems[index].remove();
}
// 提交事件处理
document.getElementById('uploadForm').addEventListener('submit', function (e) {
e.preventDefault();
const loadingOverlay = document.getElementById('loadingOverlay');
const input = document.getElementById('file');
const files = Array.from(input.files);
if (files.length === 0) {
alert('请先选择一个文件');
return;
}
// 显示加载状态
loadingOverlay.classList.remove('hidden');
const formData = new FormData();
formData.append('ragTag', document.getElementById('title').value);
files.forEach(file => formData.append('file', file));
axios.post('/api/v1/rag/file/upload', formData)
.then(response => {
if (response.data.code === '0000') {
// 成功提示并关闭窗口
setTimeout(() => {
alert('上传成功,窗口即将关闭');
window.close();
}, 500);
} else {
throw new Error(response.data.info || '上传失败');
}
})
.catch(error => {
alert(error.message);
})
.finally(() => {
// 隐藏加载状态
loadingOverlay.classList.add('hidden');
// 清空表单(无论成功与否)
input.value = '';
document.getElementById('title').value = '';
fileListElement.innerHTML = '';
});
});
</script>
</body>
</html>