一、前言
在上一节中我们完成了项目的基本环境搭建,并且测试了虚拟机中的大模型是否能够被接口调用,这一节我们将正式进入RAG的学习。
RAG 是 Retrieval-Augmented Generation 的缩写,中文意思是检索增强生成。
RAG本质上就是让大模型从一个已知的知识库中获取知识,然后可以通过提示词来进行调用,这个知识库中的知识是我们去添加的,在本项目中,这个知识库的载体是一个**向量数据库vectorDB,**所以我们只需要向这个数据库中添加数据即可。
二、知识库初始化
1.初始化数据库
首先记得向我们前面创建的pgvector容器中添加对应位置上挂载的初始化sql文件(类似于创建数据库)。

2.配置
我们首先需要配置ollama ,ollama 是一个大模型便捷安装的工具,大模型原本是需要运行在python的某些环境中的,而这个工具就可以将环境和大模型集成在一起,并且可以统一管理大模型,所以如果我们想使用大模型,首先就需要把这个工具配置好。
java
@Configuration
public class OllamaConfig {
@Bean
public OllamaApi ollamaApi(@Value("${spring.ai.ollama.base-url}") String baseUrl) {
return new OllamaApi(baseUrl);
}
@Bean
public OllamaChatClient ollamaChatClient(OllamaApi ollamaApi) {
return new OllamaChatClient(ollamaApi);
}
@Bean
public TokenTextSplitter textSplitter() {
return new TokenTextSplitter();
}
@Bean
public SimpleVectorStore simpleVectorStore(OllamaApi ollamaApi) {
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new SimpleVectorStore(embeddingClient);
}
@Bean
public PgVectorStore pgVectorStore(OllamaApi ollamaApi, JdbcTemplate jdbcTemplate){
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new PgVectorStore(jdbcTemplate,embeddingClient);
}
}
首先要明确,我们的这些配置都是基于SpringAI 框架进行配置的,所以其中的这些工具类很多都是SpringAI 提供的,因此记得要先导入SpringAI的包。
XML
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama</artifactId>
</dependency>
上一节中我们已经配置了前两个方法,这里主要拆开讲讲后面三个配置方法的意义:
java
@Bean
public TokenTextSplitter textSplitter() {
return new TokenTextSplitter();
}
这个是文本分隔器的配置,作用是将长文本按 Token 数量切分成小块(用于 RAG 的知识库入库)
配置这个可以让向量检索时小块更精准,并且**Embedding 模型(文本转向量的模型)**本身也有输入长度限制(如 512 tokens)
后面这两个配置本质是一样的,只是一个是基于内存 的,一个是基于向量数据库的,步骤都是一样的:
1.先创建 Embedding 客户端
2.然后配置使用 nomic-embed-text 模型
3.最后将 embedding 客户端传给向量数据库
java
@Bean
public SimpleVectorStore simpleVectorStore(OllamaApi ollamaApi) {
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new SimpleVectorStore(embeddingClient);
}
@Bean
public PgVectorStore pgVectorStore(OllamaApi ollamaApi, JdbcTemplate jdbcTemplate){
OllamaEmbeddingClient embeddingClient = new OllamaEmbeddingClient(ollamaApi);
embeddingClient.withDefaultOptions(OllamaOptions.create().withModel("nomic-embed-text"));
return new PgVectorStore(jdbcTemplate,embeddingClient);
}
3.测试
java
@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class RAGTest {
@Resource
private OllamaChatClient ollamaChatClient;
@Resource
private TokenTextSplitter tokenTextSplitter;
@Resource
private SimpleVectorStore simpleVectorStore;
@Resource
private PgVectorStore pgVectorStore;
@Test
public void upload() {
// 1. 读取本地知识库文件,并将文件内容解析成 Spring AI 的 Document 对象。
TikaDocumentReader tikaDocumentReader = new TikaDocumentReader("./data/file.text");
List<Document> documents = tikaDocumentReader.get();
// 2. 对原始文档进行切片,避免单个文档过长影响向量化和后续检索效果。
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
// 3. 给原始文档和切片后的文档都打上 metadata 标签,后续检索时可以按知识库过滤。
documents.forEach(doc -> doc.getMetadata().put("knowledge", "知识库1"));
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", "知识库1"));
// 4. 将切片后的文档写入 PgVector,Spring AI 会调用 embedding 模型生成向量后入库。
pgVectorStore.accept(documentSplitterList);
log.info("上传完成");
}
@Test
public void chat() {
// 1. 用户输入的问题,后续会用它去向量库里做相似度检索。
String message = "印东升是哪年出生的";
// 2. 构建 RAG 系统提示词模板,把检索出来的知识片段填充到 DOCUMENTS 占位符里。
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";
// 3. 构建向量检索请求:取最相似的 5 条,并只检索指定 knowledge 标签的数据。
SearchRequest request = SearchRequest.query(message).withTopK(5).withFilterExpression("knowledge == '知识库1'");
// 4. 根据用户问题从 PgVector 中检索相关文档片段。
List<Document> documents = pgVectorStore.similaritySearch(request);
// 5. 把检索到的多个文档片段拼接成一段上下文,准备填入系统提示词。
String documentCollectors = documents.stream().map(Document::getContent).collect(Collectors.joining());
// 6. 使用检索结果创建系统消息,让大模型回答时优先依据知识库内容。
Message ragMessage = new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentCollectors));
// 7. 组装最终发送给大模型的消息列表:用户问题 + RAG 系统上下文。
ArrayList<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(ragMessage);
// 8. 调用 Ollama 模型生成回答,模型名必须和 Ollama 中已安装的模型一致。
ChatResponse chatResponse = ollamaChatClient.call(new Prompt(messages, OllamaOptions.create().withModel("deepseek-r1:1.5b")));
log.info("测试结果:{}", JSON.toJSONString(chatResponse));
}
}
这里面注释已经写得很详细了,但是这整个过程其实可以当作是一个模板,我现在也对这些API不太熟悉,不过在后续的学习中我们会逐步熟悉的。
结果如下:

最后我给出我画的流程图,便于理解代码:

三、RAG接口和UI界面对接
其实这里本质就是把之前测试的数据给一般化了,比如之前的知识库名和上传文件的地址都是写死的,现在我们只需要把这些东西当作参数,直接编写接口即可。
1.API接口
几乎和上面的测试没多大区别,只是用Response包装了一下返回对象,然后使用Redis的List存储已经上传过的知识库名。
java
/**
* @author 印东升
* @description
* @create 2026-05-20 14:34
*/
@Slf4j
@RestController
@CrossOrigin("*")
@RequestMapping("/api/v1/rag/")
public class RAGController implements IRAGService {
@Resource
private OllamaChatClient ollamaChatClient;
@Resource
private TokenTextSplitter tokenTextSplitter;
@Resource
private SimpleVectorStore simpleVectorStore;
@Resource
private PgVectorStore pgVectorStore;
@Resource
private RedissonClient redissonClient;
@RequestMapping(value = "query_rag_tag_list", method = RequestMethod.GET)
@Override
public Response<List<String>> queryRagTagList() {
RList<String> elements = redissonClient.getList("ragTag");
return Response.<List<String>>builder()
.code("0000")
.info("调用成功")
.data(elements)
.build();
}
@RequestMapping(value = "file/upload", method = RequestMethod.POST, headers = "content-type=multipart/form-data")
@Override
public Response<String> uploadFile(@RequestParam("ragTag") String ragTag, @RequestParam("file") List<MultipartFile> files) {
log.info("上传知识库开始:{}", ragTag);
for (MultipartFile file : files) {
TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
List<Document> documents = documentReader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
documents.forEach(doc -> doc.getMetadata().put("knowledge", ragTag));
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", ragTag));
pgVectorStore.accept(documentSplitterList);
RList<String> elements = redissonClient.getList("ragTag");
if (!elements.contains(ragTag)) {
elements.add(ragTag);
}
}
log.info("上传知识库完成:{}", ragTag);
return Response.<String>builder()
.code("0000")
.info("调用成功")
.build();
}
}
2.AI生成UI界面
使用提示词生成界面:
bash
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/ollama/")
public class OllamaController implements IAiService {
@RequestMapping(value = "file/upload", method = RequestMethod.POST, headers = "content-type=multipart/form-data")
@Override
public Response<String> uploadFile(@RequestParam String ragTag, @RequestParam("file") List<MultipartFile> files) {
log.info("上传知识库开始 {}", ragTag);
for (MultipartFile file : files) {
TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
List<Document> documents = documentReader.get();
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);
// 添加知识库标签
documents.forEach(doc -> doc.getMetadata().put("knowledge", ragTag));
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", ragTag));
pgVectorStore.accept(documentSplitterList);
// 添加知识库记录
RList<String> elements = redissonClient.getList("ragTag");
if (!elements.contains(ragTag)) {
elements.add(ragTag);
}
}
log.info("上传知识库完成 {}", ragTag);
return Response.<String>builder().code("0000").info("调用成功").build();
}
}
- 请根据服务端接口,编写一款好看的前端上传页面。页面使用 html、js、tailwindcss 编写,不要提供 vue、react 代码。
- ragTag 为知识库名称
- files 为知识库文件,支持,md、txt、sql 文件类型上传。
我用DeepSeek生成的html页面代码如下:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>知识库管理系统 | 智能文件上传</title>
<!-- Tailwind CSS v3 + Font Awesome Icons -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<!-- 额外微调样式 -->
<style>
/* 自定义拖拽高亮效果 */
.drag-over {
background-color: #eef2ff !important;
border-color: #3b82f6 !important;
transform: scale(0.98);
transition: all 0.2s ease;
}
/* 文件列表动画 */
.file-item {
transition: all 0.2s ease;
}
.file-item:hover {
transform: translateX(4px);
}
/* 自定义滚动条 */
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* 加载动画 */
@keyframes spin-slow {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.animate-spin-slow {
animation: spin-slow 1s linear infinite;
}
/* 波浪背景点缀 */
.bg-dots {
background-image: radial-gradient(#e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
</style>
</head>
<body class="bg-gradient-to-br from-slate-50 to-blue-50 font-sans antialiased min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- 头部区域 -->
<div class="text-center mb-10">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-tr from-indigo-500 to-blue-600 shadow-lg mb-4">
<i class="fas fa-database text-white text-3xl"></i>
</div>
<h1 class="text-3xl md:text-4xl font-bold text-slate-800 tracking-tight">知识库<span class="text-indigo-600"> · 智能上传</span></h1>
<p class="text-slate-500 mt-2 max-w-2xl mx-auto">支持 Markdown(.md)、文本(.txt)、SQL脚本(.sql) 文件上传,构建专属RAG知识库</p>
</div>
<!-- 双栏布局:左侧表单+拖拽区,右侧知识库列表 -->
<div class="grid lg:grid-cols-3 gap-8">
<!-- 左侧主要区域:上传核心 (2/3 宽度实际上通过grid比例调整 3列中占2) -->
<div class="lg:col-span-2 space-y-6">
<!-- 知识库名称卡片 -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden transition-all hover:shadow-md">
<div class="px-6 py-4 bg-gradient-to-r from-indigo-50 to-transparent border-b border-slate-100">
<div class="flex items-center gap-2">
<i class="fas fa-tag text-indigo-500"></i>
<h2 class="font-semibold text-slate-700">知识库标签 (Rag Tag)</h2>
<span class="text-xs text-slate-400 ml-auto">唯一标识</span>
</div>
</div>
<div class="p-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<input type="text" id="ragTagInput" placeholder="例如: 技术文档, 客服知识库, 产品手册"
class="w-full px-4 py-3 border border-slate-300 rounded-xl focus:ring-2 focus:ring-indigo-300 focus:border-indigo-400 transition outline-none text-slate-700">
<p class="text-xs text-slate-400 mt-1"><i class="far fa-lightbulb"></i> 知识库名称将用于向量检索分类</p>
</div>
<div class="sm:w-auto">
<button id="clearTagBtn" class="px-4 py-3 text-slate-500 hover:text-indigo-600 transition text-sm border border-slate-200 rounded-xl bg-slate-50 hover:bg-indigo-50">
<i class="fas fa-eraser mr-1"></i> 清空
</button>
</div>
</div>
</div>
</div>
<!-- 文件上传区域 (支持拖拽) -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-6 py-4 bg-gradient-to-r from-indigo-50 to-transparent border-b border-slate-100 flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-cloud-upload-alt text-indigo-500"></i>
<h2 class="font-semibold text-slate-700">选择文件 / 拖拽上传</h2>
</div>
<span class="text-xs bg-slate-100 px-2 py-1 rounded-full text-slate-500"><i class="far fa-file-alt"></i> 支持 .md .txt .sql</span>
</div>
<!-- 拖拽区域 + 隐藏input -->
<div class="p-6">
<div id="dropzone"
class="relative border-2 border-dashed border-slate-300 rounded-xl p-8 text-center cursor-pointer transition-all bg-slate-50/40 hover:bg-indigo-50/20">
<input type="file" id="fileInput" multiple accept=".md,.txt,.sql,.MD,.TXT,.SQL" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<div class="flex flex-col items-center gap-3 pointer-events-none">
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-500">
<i class="fas fa-folder-open text-2xl"></i>
</div>
<div>
<p class="text-slate-600 font-medium">点击或拖拽文件至此区域</p>
<p class="text-sm text-slate-400 mt-1">支持 .md / .txt / .sql 格式,可多选</p>
</div>
<div class="flex gap-2 text-xs text-slate-400">
<span class="bg-slate-100 px-2 py-0.5 rounded"><i class="fab fa-markdown"></i> Markdown</span>
<span class="bg-slate-100 px-2 py-0.5 rounded"><i class="fas fa-file-alt"></i> TXT</span>
<span class="bg-slate-100 px-2 py-0.5 rounded"><i class="fas fa-database"></i> SQL</span>
</div>
</div>
</div>
<!-- 已选文件列表展示区 -->
<div id="selectedFilesContainer" class="mt-5 hidden">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-slate-600"><i class="fas fa-list-ul mr-1"></i> 待上传文件 (<span id="fileCount">0</span>)</h3>
<button id="clearFilesBtn" class="text-xs text-red-400 hover:text-red-600 transition">清空全部</button>
</div>
<div id="fileList" class="max-h-48 overflow-y-auto custom-scroll space-y-2 pr-1">
<!-- 动态文件列表 -->
</div>
</div>
<!-- 上传按钮与状态 -->
<div class="mt-6 flex flex-wrap gap-3 items-center justify-between">
<button id="uploadBtn"
class="bg-gradient-to-r from-indigo-600 to-blue-600 hover:from-indigo-700 hover:to-blue-700 text-white font-medium px-6 py-3 rounded-xl shadow-md transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<i class="fas fa-upload"></i> 上传至知识库
</button>
<div id="uploadStatus" class="text-sm text-slate-500 flex items-center gap-2">
<i class="fas fa-info-circle"></i> 等待操作
</div>
</div>
</div>
</div>
<!-- 上传记录/帮助卡片 -->
<div class="bg-white/70 backdrop-blur-sm rounded-xl border border-slate-100 p-4 text-xs text-slate-500 flex gap-3 flex-wrap">
<div><i class="fas fa-check-circle text-green-500 mr-1"></i> 文件将被分块存储至向量库</div>
<div><i class="fas fa-tag text-indigo-400 mr-1"></i> 同一个知识库标签下文件会关联检索</div>
<div><i class="fas fa-shield-alt"></i> 支持跨域请求 (CORS已开放)</div>
</div>
</div>
<!-- 右侧:历史知识库列表 & 上传动态 -->
<div class="lg:col-span-1 space-y-6">
<!-- 已有知识库列表卡片 -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden sticky top-8">
<div class="px-5 py-4 bg-gradient-to-r from-indigo-50/50 to-transparent border-b border-slate-100">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fas fa-layer-group text-indigo-500"></i>
<h2 class="font-semibold text-slate-700">已有知识库</h2>
</div>
<button id="refreshTagListBtn" class="text-indigo-500 hover:text-indigo-700 transition text-sm">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div class="p-3 max-h-96 overflow-y-auto custom-scroll">
<div id="ragTagList" class="space-y-2">
<!-- 动态加载标签列表 -->
<div class="text-center py-8 text-slate-400 text-sm">
<i class="fas fa-spinner fa-pulse"></i> 加载知识库列表...
</div>
</div>
<div class="mt-3 pt-2 border-t border-slate-100 text-center">
<button id="syncTagsBtn" class="text-xs text-indigo-400 hover:text-indigo-600"><i class="fas fa-database"></i> 同步最新标签</button>
</div>
</div>
<div class="bg-slate-50 px-4 py-2 text-[11px] text-slate-400 border-t border-slate-100">
<i class="fas fa-info-circle"></i> 知识库标签存储于Redisson List
</div>
</div>
<!-- 上传提示卡片 -->
<div class="bg-indigo-50/40 rounded-xl p-4 border border-indigo-100">
<div class="flex gap-3">
<i class="fas fa-lightbulb text-indigo-400 text-xl"></i>
<div class="text-xs text-slate-600">
<p class="font-medium">✨ 使用技巧</p>
<ul class="list-disc list-inside mt-1 space-y-0.5 text-slate-500">
<li>知识库名称将作为元数据`knowledge`字段</li>
<li>支持批量上传多个文件,自动解析文本</li>
<li>上传成功后可刷新右侧列表查看标签</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 轻提示自定义组件 -->
<div id="toast" class="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50 hidden transition-all duration-300">
<div class="bg-slate-800 text-white px-5 py-3 rounded-full shadow-lg flex items-center gap-2 text-sm backdrop-blur-sm">
<i id="toastIcon" class="fas fa-check-circle"></i>
<span id="toastMessage">提示消息</span>
</div>
</div>
<script>
// ---------- DOM 元素绑定 ----------
const ragTagInput = document.getElementById('ragTagInput');
const clearTagBtn = document.getElementById('clearTagBtn');
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const selectedFilesContainer = document.getElementById('selectedFilesContainer');
const fileListDiv = document.getElementById('fileList');
const fileCountSpan = document.getElementById('fileCount');
const clearFilesBtn = document.getElementById('clearFilesBtn');
const uploadBtn = document.getElementById('uploadBtn');
const uploadStatusSpan = document.getElementById('uploadStatus');
const ragTagListDiv = document.getElementById('ragTagList');
const refreshTagListBtn = document.getElementById('refreshTagListBtn');
const syncTagsBtn = document.getElementById('syncTagsBtn');
// 全局存储当前待上传文件列表 (File对象数组)
let pendingFiles = [];
// API 基础地址 (根据实际后端地址修改,此处默认支持跨域)
// 如果部署到不同端口,请修改这个常量
const API_BASE_URL = 'http://localhost:8090/api/v1/rag/';
// 展示 Toast 消息
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
const toastIcon = document.getElementById('toastIcon');
const toastMessage = document.getElementById('toastMessage');
if (!toast) return;
toastIcon.className = isError ? 'fas fa-exclamation-triangle text-amber-400' : 'fas fa-check-circle text-green-400';
toastMessage.innerText = message;
toast.classList.remove('hidden');
setTimeout(() => {
toast.classList.add('hidden');
}, 3000);
}
// 更新待上传文件列表UI
function renderPendingFiles() {
if (pendingFiles.length === 0) {
selectedFilesContainer.classList.add('hidden');
fileListDiv.innerHTML = '';
fileCountSpan.innerText = '0';
return;
}
selectedFilesContainer.classList.remove('hidden');
fileCountSpan.innerText = pendingFiles.length;
const filesHtml = pendingFiles.map((file, idx) => {
const fileSize = (file.size / 1024).toFixed(1);
const ext = file.name.split('.').pop().toLowerCase();
let iconClass = 'fa-file-alt';
if (ext === 'md') iconClass = 'fab fa-markdown';
if (ext === 'sql') iconClass = 'fa-database';
return `
<div class="file-item flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2 border border-slate-100">
<div class="flex items-center gap-2 overflow-hidden">
<i class="${iconClass} text-indigo-400 w-5"></i>
<span class="text-sm text-slate-700 truncate max-w-[180px]" title="${file.name}">${file.name}</span>
<span class="text-xs text-slate-400">(${fileSize} KB)</span>
</div>
<button class="remove-file-btn text-slate-400 hover:text-red-500 transition" data-index="${idx}">
<i class="fas fa-times-circle"></i>
</button>
</div>
`;
}).join('');
fileListDiv.innerHTML = filesHtml;
// 绑定移除按钮事件
document.querySelectorAll('.remove-file-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(btn.getAttribute('data-index'));
if (!isNaN(idx) && idx >= 0 && idx < pendingFiles.length) {
pendingFiles.splice(idx, 1);
renderPendingFiles();
// 重置 fileInput 的 value 防止重复添加相同文件时无法再次触发 change? 不用重置太深,用户再次选择会合并处理, 为了避免混乱不清空input也可以,但建议保留文件列表独立。
// 不影响后续新增
}
});
});
}
// 添加文件到待上传列表 (去重:根据文件名+大小+修改时间简单去重)
function addFiles(newFiles) {
const validFiles = [];
for (let file of newFiles) {
const ext = file.name.split('.').pop().toLowerCase();
if (['md', 'txt', 'sql'].includes(ext)) {
// 简单去重: 同一会话里已存在的同名且大小相同的不再加
const exists = pendingFiles.some(f => f.name === file.name && f.size === file.size);
if (!exists) {
validFiles.push(file);
}
} else {
showToast(`文件 ${file.name} 格式不支持,请上传 .md/.txt/.sql`, true);
}
}
if (validFiles.length > 0) {
pendingFiles.push(...validFiles);
renderPendingFiles();
} else if (newFiles.length > 0 && validFiles.length === 0) {
showToast('所选文件均不符合支持的类型', true);
}
}
// 清空所有待上传文件
function clearAllFiles() {
pendingFiles = [];
renderPendingFiles();
fileInput.value = ''; // 清除input的值,避免重复选择相同文件不触发change
}
// 上传逻辑 (调用后端接口)
async function uploadFiles() {
const ragTag = ragTagInput.value.trim();
if (!ragTag) {
showToast('请填写知识库名称 (RagTag)', true);
ragTagInput.focus();
return;
}
if (pendingFiles.length === 0) {
showToast('请先选择要上传的文件', true);
return;
}
// 准备 FormData
const formData = new FormData();
formData.append('ragTag', ragTag);
for (let file of pendingFiles) {
formData.append('file', file); // 后端接收 List<MultipartFile> files
}
// 更新按钮状态及文本
uploadBtn.disabled = true;
const originalBtnHtml = uploadBtn.innerHTML;
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-pulse"></i> 上传中...';
uploadStatusSpan.innerHTML = '<i class="fas fa-spinner fa-pulse"></i> 正在上传至知识库,请稍候...';
try {
const response = await fetch(`${API_BASE_URL}file/upload`, {
method: 'POST',
body: formData,
// 注意不要设置 Content-Type,浏览器自动设置boundary
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`服务器响应错误 ${response.status}: ${errorText}`);
}
const result = await response.json();
if (result.code === '0000') {
showToast(`成功上传 ${pendingFiles.length} 个文件到知识库「${ragTag}」`);
uploadStatusSpan.innerHTML = `<i class="fas fa-check-circle text-green-500"></i> 上传成功!文件已入库`;
// 成功后清空已选文件,保留ragTag不清空,但可保留方便连续上传
clearAllFiles();
// 刷新右侧知识库列表
await fetchRagTagList();
// 可选: 清空tag? 不自动清空,但让用户知道成功
} else {
throw new Error(result.info || '上传失败,后端返回异常');
}
} catch (error) {
console.error('上传错误:', error);
let errorMsg = error.message || '网络错误或服务端未启动';
showToast(`上传失败: ${errorMsg}`, true);
uploadStatusSpan.innerHTML = `<i class="fas fa-exclamation-triangle text-red-500"></i> 上传失败,请检查后端服务`;
} finally {
uploadBtn.disabled = false;
uploadBtn.innerHTML = originalBtnHtml;
// 3秒后重置状态文字, 但不移除成功标志
setTimeout(() => {
if (uploadStatusSpan.innerHTML.includes('成功') || uploadStatusSpan.innerHTML.includes('失败')) {
uploadStatusSpan.innerHTML = '<i class="fas fa-info-circle"></i> 准备就绪,可继续上传';
}
}, 4000);
}
}
// 获取已有知识库列表 (调用哪个接口? 根据后端逻辑,Redisson维护ragTag列表,需要后端提供获取接口。假定额外提供 GET /api/v1/ollama/rag-tags)
// 若后端没有提供列表接口,我们可以基于前端展示,但需求中没有明确接口提供,故前端可以通过模拟或者实现 GET 请求 /api/v1/ollama/rag-tags 接口
// 尽量适配潜在的后台接口,如果没有该接口,提供模拟数据或提示。但是更优雅: 根据设计,上传时会记录标签,可以尝试调用 GET 获取rag标签列表。
// 为了完整,我们假设后端存在一个获取所有知识库标签的接口 GET /api/v1/ollama/rag-tags 返回 {"code":"0000","data":["tag1","tag2"]}
// 如果不存在该接口,会展示错误,用户可以手动同步刷新但需要后端配合。这里提供稳健调用,提示用户后端需补充。
async function fetchRagTagList() {
ragTagListDiv.innerHTML = '<div class="text-center py-4 text-slate-400 text-sm"><i class="fas fa-spinner fa-pulse"></i> 加载知识库列表...</div>';
try {
// 尝试调用获取标签列表接口(若未实现会404,捕获异常展示友好提示)
const response = await fetch(`${API_BASE_URL}query_rag_tag_list`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.code === '0000' && Array.isArray(result.data)) {
const tags = result.data;
if (tags.length === 0) {
ragTagListDiv.innerHTML = `<div class="text-center py-6 text-slate-400 text-sm"><i class="far fa-folder-open"></i> 暂无知识库标签,上传后显示</div>`;
return;
}
const tagsHtml = tags.map(tag => `
<div class="flex items-center justify-between bg-slate-50 hover:bg-indigo-50 rounded-lg px-3 py-2 transition cursor-pointer group" onclick="quickFillTag('${escapeHtml(tag)}')">
<div class="flex items-center gap-2">
<i class="fas fa-hashtag text-indigo-400 text-xs"></i>
<span class="text-sm text-slate-700 truncate">${escapeHtml(tag)}</span>
</div>
<button class="tag-use-btn text-indigo-400 opacity-0 group-hover:opacity-100 transition text-xs" data-tag="${escapeHtml(tag)}" onclick="event.stopPropagation();quickFillTag('${escapeHtml(tag)}')">
<i class="fas fa-pen"></i> 选用
</button>
</div>
`).join('');
ragTagListDiv.innerHTML = tagsHtml;
} else {
throw new Error('数据格式异常');
}
} catch (err) {
console.warn("获取知识库列表失败,可能后端未提供 /rag-tags 接口", err);
ragTagListDiv.innerHTML = `<div class="text-center py-6 text-amber-600 text-xs">
<i class="fas fa-plug"></i> 后端未提供标签列表接口<br>
<span class="text-slate-400">上传后标签将自动记录</span>
<div class="mt-2"><button id="manualHintBtn" class="text-indigo-400 underline text-xs">了解详情</button></div>
</div>`;
const hintBtn = document.getElementById('manualHintBtn');
if(hintBtn) hintBtn.onclick = () => showToast("请在后端实现 GET /api/v1/ollama/rag-tags 返回标签数组", false);
}
}
// 快速填充知识库名称
window.quickFillTag = function(tag) {
ragTagInput.value = tag;
showToast(`已选用知识库「${tag}」`, false);
};
function escapeHtml(str) {
if(!str) return '';
return str.replace(/[&<>]/g, function(m) {
if(m === '&') return '&';
if(m === '<') return '<';
if(m === '>') return '>';
return m;
});
}
// 拖拽事件处理
function initDragAndDrop() {
const dropZoneEl = document.getElementById('dropzone');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZoneEl.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZoneEl.addEventListener(eventName, () => {
dropZoneEl.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZoneEl.addEventListener(eventName, () => {
dropZoneEl.classList.remove('drag-over');
}, false);
});
dropZoneEl.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files && files.length) {
addFiles(Array.from(files));
}
}, false);
// 点击触发文件选择
dropZoneEl.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files.length) {
addFiles(Array.from(e.target.files));
fileInput.value = ''; // 清空value,以便再次选择同样文件能触发change
}
});
}
// 清除所有文件的按钮
clearFilesBtn.addEventListener('click', clearAllFiles);
clearTagBtn.addEventListener('click', () => { ragTagInput.value = ''; });
uploadBtn.addEventListener('click', uploadFiles);
refreshTagListBtn?.addEventListener('click', fetchRagTagList);
syncTagsBtn?.addEventListener('click', fetchRagTagList);
// 初始化加载已有标签 && 设置默认placeholder示例
function init() {
initDragAndDrop();
fetchRagTagList().catch(()=>{});
// 示例默认知识库名称提示
ragTagInput.placeholder = "例如: 技术文档库 / 客服FAQ";
// 监听输入框回车快捷
ragTagInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') uploadFiles();
});
// 初始为空文件列表
renderPendingFiles();
}
init();
</script>
</body>
</html>