7.29 修改前端页面展示
This commit is contained in:
parent
1ea68cb74f
commit
81b71d0d07
@ -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;
|
||||
|
||||
|
@ -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 仓库:
|
||||
* - 克隆指定仓库到本地
|
||||
|
@ -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.
|
||||
|
52
docs/tag/v1.0/nginx/html/css/upload.css
Normal file
52
docs/tag/v1.0/nginx/html/css/upload.css
Normal 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;
|
||||
}
|
@ -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 = `
|
||||
<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();
|
||||
};
|
||||
|
383
docs/tag/v1.0/nginx/html/js/upload.js
Normal file
383
docs/tag/v1.0/nginx/html/js/upload.js
Normal 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 = [];
|
||||
|
||||
// 方案 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();
|
||||
}
|
||||
});
|
@ -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" />
|
||||
|
||||
<!-- Axios(CDN 版本;也可改回你本地的 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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user