1. 环境安装配置
此工程使用 Docker 部署 ollama、postgresql、redis 环境的脚本。因为使用 Docker 可以随时方便卸载,不会污染本地电脑的本机环境(Docker 相当于架在你电脑系统上的一个单独安全区域的虚拟环境)。而且后续部署 Linux 云服务器也会非常顺手。
1.1 Dokcer 安装
关于 Docker 安装教程,请阅读这篇文章来完成安装:Docker命令记录。
此工程使用 Docker 部署 ollama、postgresql、redis 环境的脚本。因为使用 Docker 可以随时方便卸载,不会污染本地电脑的本机环境(Docker 相当于架在你电脑系统上的一个单独安全区域的虚拟环境)。而且后续部署 Linux 云服务器也会非常顺手。
关于 Docker 安装教程,请阅读这篇文章来完成安装:Docker命令记录。
本章主要介绍。集成 Spring AI 框架的组件,通过对接 Ollama DeepSeek 实现服务接口的开发,包括普通应答接口和流式应答接口。
目前对接 AI 的方式多种多样,例如:通过 AI 官网提供的 SDK、基于 自研 SDK 组件 或采用 One-API 服务类统一封装接口。为了探索不同的技术方案并提升学习多样性,本项目将采用 Spring AI 框架来完成与 Ollama DeepSeek 的对接。
在有了 AI 工具以后,对后端程序员来说,之前比较繁琐头疼的前端页面,现在非常轻松的就能实现了。
只要我们可以清楚地表达编写页面诉求,AI 工具就可以非常准确且迅速的完成代码的实现。
这里我们可以选择的 AI 有很多,包括;OpenAI、DeepSeek、智谱AI等等。
以大模型向量存储的方式,将本地文件自定义方式组成知识库。并在 AI 对话中增强检索知识库符合 AI 对话内容的资料,合并提交问题。
本技术方案利用 Spring AI 提供的向量模型处理框架,首先通过 TikaDocumentReader 解析上传的文件,再使用 TokenTextSplitter 对文件内容进行拆分。完成这些操作后,在对拆分后的文档进行标记,以便区分不同的知识库内容。标记的作用是为了可以区分不同的知识库内容。完成这些动作后,把这些拆解并打标的文件存储到 postgresql 向量库中。
本技术方案旨在利用 Spring AI 提供的向量模型处理框架,对上传的文件进行解析、拆分、标记,并将处理后的数据存储到 PostgreSQL 向量库中。通过这一流程,可以实现对文件内容的高效管理和检索,特别是在需要区分不同知识库内容的场景下。
根据前一章节知识库的测试案例,本章将会把测试案例封装成接口形式提供给外部使用。
知识库的上传和使用流程清晰,但需要明确选择哪个知识库,并记录相关信息。为此,我们采用 Redis 列表来高效记录知识库的选择。对于公司级的大型知识库,则需使用 MySQL 数据库进行持久化存储,以满足更高的数据管理需求。
基于前一章节的内容,实现在前端页面与大模型对话和自定义知识库的上传和使用。
搜索一个合适的喜欢的AI对话页面,然后截取图片上传到 AI 工具,并告知基于这样的 UI 效果完成页面的实现。
之后在告诉 AI 处理接口的对接。当然也可以把接口一起交给 AI 工具进行处理。
扩展本地自定义知识库的解析功能,新增 Git 仓库解析。用户输入 Git 仓库地址和账号密码,即可拉取代码并上传至知识库,随后便可基于此代码进行使用。
将 JGit 操作库引入工程,用于执行 Git 命令拉取代码仓库。随后遍历代码库文件,依次解析、分割并上传至向量库。
扩展本地自定义知识库的解析功能,新增 Git 仓库解析。用户输入 Git 仓库地址和账号密码,即可拉取代码并上传至知识库,随后便可基于此代码进行使用。
采用Spring AI 框架实现对接 OpenAI ,优势在于能够以统一方式直接配置和使用各类大模型。对于未直接对接的大模型,可通过 one-api 配置转发,采用统一的 OpenAI 方式进行对接。
本章主要讲如何讲项目部署到有公网的服务器上,练习 Linux、Docker、Nginx的线上操作。
在开始前优化上一章节遗留问题:添加模型上下文记忆功能。
修改项目中docs/dev-ops/nginx/html/js/index.js
文件里 startEventStream
方法:
function startEventStream(message) {
if (isStreaming) return;
if (!currentChatId) {
console.error("Cannot start stream without a current chat ID.");
return;
}
setStreamingState(true);
if (currentEventSource) {
currentEventSource.close();
}
const selectedRagTag = ragSelect.value; // Keep RAG selection logic
const selectedAiModelValue = aiModelSelect.value;
const selectedAiModelName = aiModelSelect.options[aiModelSelect.selectedIndex].getAttribute('model');
if (!selectedAiModelName) {
console.error("No AI model name selected!");
setStreamingState(false);
appendMessage("错误:未选择有效的 AI 模型。", true, false);
return;
}
// --- WORKAROUND START ---
// 1. Get chat history
const chatData = getChatData(currentChatId);
const history = chatData ? chatData.messages : [];
// 2. Format history into a single string to prepend
let historyString = "";
// Limit history length to avoid excessively long URLs (adjust maxHistory as needed)
const maxHistory = 10; // Example: Keep last 10 messages
const startIndex = Math.max(0, history.length - maxHistory);
for (let i = startIndex; i < history.length; i++) {
const msg = history[i];
if (typeof msg.content === 'string' && typeof msg.isAssistant === 'boolean') {
// Important: Exclude <think> tags from the history string sent to the model
// unless your model is specifically trained to handle them as part of the prompt.
// Usually, you only want the actual conversation turns.
const contentWithoutThink = msg.content.replace(/<think>.*?<\/think>/gs, '').trim();
if (contentWithoutThink) { // Only add if there's actual content after removing think tags
historyString += (msg.isAssistant ? "Assistant: " : "User: ") + contentWithoutThink + "\n";
}
}
}
// 3. Prepend history to the current message
const combinedMessage = historyString + "User: " + message; // Clearly mark the new message
// --- WORKAROUND END ---
let url;
const base = `http://localhost:7080/api/v1/${selectedAiModelValue}`;
// --- Send the COMBINED message in the 'message' parameter ---
const params = new URLSearchParams({
// Send the combined history+current message string
message: combinedMessage,
model: selectedAiModelName
// NO 'history' parameter here
});
if (selectedRagTag) {
params.append('ragTag', selectedRagTag);
// Decide how RAG interacts with history prepending.
// Does the backend RAG process need the raw message or the combined one?
// Assuming backend handles RAG based on the full 'message' param for now.
url = `${base}/generate_stream_rag?${params.toString()}`;
} else {
url = `${base}/generate_stream?${params.toString()}`;
}
// --- END MODIFICATION ---
console.log("Streaming URL (Workaround):", url); // URL will have a long 'message' param
console.log("Combined Message sent:", combinedMessage); // Log the combined string
currentEventSource = new EventSource(url);
let accumulatedContent = '';
let tempMessageWrapper = null;
let streamEnded = false;
const messageId = `ai-message-${Date.now()}`;
// --- Create Unified Placeholder ---
if (chatArea.style.display === 'none') {
chatArea.style.display = 'block';
welcomeMessage.style.display = 'none';
}
tempMessageWrapper = document.createElement('div');
tempMessageWrapper.className = 'flex w-full mb-4 message-bubble justify-start';
tempMessageWrapper.id = messageId;
tempMessageWrapper.innerHTML = `
<div class="flex gap-3 max-w-4xl w-full">
<div class="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0 mt-1 shadow-sm">
<span class="text-green-600 text-sm font-semibold">AI</span>
</div>
<div class="message-content-wrapper bg-white border border-gray-200 px-4 py-3 rounded-lg shadow-sm min-w-[80px] flex-grow markdown-body">
<span class="streaming-cursor animate-pulse">▋</span>
</div>
</div>
`;
chatArea.appendChild(tempMessageWrapper);
let messageContentWrapper = tempMessageWrapper.querySelector('.message-content-wrapper');
let hasInjectedDetails = false;
requestAnimationFrame(() => {
chatArea.scrollTop = chatArea.scrollHeight;
});
// The rest of the onmessage, onerror, DOM handling logic remains the same
// as in the previous snippet. The AI's response (accumulatedContent)
// is still processed and displayed identically.
currentEventSource.onmessage = function (event) {
// ... (SAME onmessage logic as before - handling stream data, <think> tags, DOM updates) ...
if (streamEnded) return;
try {
const data = JSON.parse(event.data);
if (data.result?.output?.text !== undefined) {
const newContent = data.result.output.text ?? '';
accumulatedContent += newContent;
// --- Process Accumulated Content ---
const thinkRegex = /<think>(.*?)<\/think>/gs;
let thinkingSteps = '';
let match;
const localThinkRegex = /<think>(.*?)<\/think>/gs;
while ((match = localThinkRegex.exec(accumulatedContent)) !== null) {
thinkingSteps += match[1] + '\n';
}
thinkingSteps = thinkingSteps.trim();
const finalAnswer = accumulatedContent.replace(/<think>.*?<\/think>/gs, '').trim();
// --- Update DOM Dynamically ---
messageContentWrapper = document.getElementById(messageId)?.querySelector('.message-content-wrapper');
if (!messageContentWrapper) {
console.error("Message wrapper not found!");
return;
}
if (thinkingSteps && !hasInjectedDetails) {
messageContentWrapper.innerHTML = `
<details class="thinking-process" open>
<summary class="cursor-pointer text-sm text-gray-600 hover:text-gray-800 mb-2 focus:outline-none select-none">
思考过程... <span class="text-xs opacity-70">(点击展开/折叠)</span>
</summary>
<div class="thinking-steps-content markdown-body border-t border-gray-100 pt-2 pl-2 text-xs opacity-80 min-h-[20px]">
</div>
</details>
<div class="final-answer markdown-body pt-3">
</div>
`;
hasInjectedDetails = true;
}
// --- Populate Content ---
if (hasInjectedDetails) {
const thinkingStepsDiv = messageContentWrapper.querySelector('.thinking-steps-content');
const finalAnswerDiv = messageContentWrapper.querySelector('.final-answer');
if (thinkingStepsDiv) {
thinkingStepsDiv.innerHTML = sanitizeHTML(marked.parse(thinkingSteps + '<span class="streaming-cursor animate-pulse">▋</span>'));
applyHighlightingAndCopyButtons(thinkingStepsDiv);
}
if (finalAnswerDiv) {
finalAnswerDiv.innerHTML = finalAnswer
? sanitizeHTML(marked.parse(finalAnswer))
: '<span class="text-gray-400 text-sm">正在处理...</span>';
applyHighlightingAndCopyButtons(finalAnswerDiv);
}
} else {
messageContentWrapper.innerHTML = sanitizeHTML(marked.parse(finalAnswer + '<span class="streaming-cursor animate-pulse">▋</span>'));
applyHighlightingAndCopyButtons(messageContentWrapper);
}
requestAnimationFrame(() => {
chatArea.scrollTop = chatArea.scrollHeight;
});
}
// --- Handle Stream End ---
if (data.result?.metadata?.finishReason === 'stop' || data.result?.metadata?.finishReason === 'STOP') {
streamEnded = true;
currentEventSource.close();
// --- Final Processing of accumulatedContent ---
const thinkRegex = /<think>(.*?)<\/think>/gs;
let finalThinkingSteps = '';
let match;
const localThinkRegex = /<think>(.*?)<\/think>/gs; // Use new instance
while ((match = localThinkRegex.exec(accumulatedContent)) !== null) {
finalThinkingSteps += match[1] + '\n';
}
finalThinkingSteps = finalThinkingSteps.trim();
const finalFinalAnswer = accumulatedContent.replace(/<think>.*?<\/think>/gs, '').trim();
// --- Final DOM Update ---
messageContentWrapper = document.getElementById(messageId)?.querySelector('.message-content-wrapper');
if (!messageContentWrapper) {
console.error("Message wrapper not found for final update!");
return;
}
messageContentWrapper.querySelectorAll('.streaming-cursor').forEach(c => c.remove());
if (finalThinkingSteps) {
if (!hasInjectedDetails) {
messageContentWrapper.innerHTML = `
<details class="thinking-process" open>
<summary>思考过程 <span class="text-xs opacity-70">(来自历史记录)</span></summary>
<div class="thinking-steps-content markdown-body border-t border-gray-100 pt-2 pl-2 text-xs opacity-80"></div>
</details>
<div class="final-answer markdown-body pt-3"></div>
`;
hasInjectedDetails = true;
}
const thinkingStepsDiv = messageContentWrapper.querySelector('.thinking-steps-content');
const finalAnswerDiv = messageContentWrapper.querySelector('.final-answer');
if (thinkingStepsDiv) {
thinkingStepsDiv.innerHTML = sanitizeHTML(marked.parse(finalThinkingSteps));
applyHighlightingAndCopyButtons(thinkingStepsDiv);
} else {
console.error("Thinking steps div not found in final update!");
}
if (finalAnswerDiv) {
finalAnswerDiv.innerHTML = finalFinalAnswer ? sanitizeHTML(marked.parse(finalFinalAnswer)) : '';
applyHighlightingAndCopyButtons(finalAnswerDiv);
} else {
console.error("Final answer div not found in final update!");
}
// Update summary text after completion
const summaryElement = messageContentWrapper.querySelector('.thinking-process summary');
if (summaryElement) summaryElement.innerHTML = `思考过程 <span class="text-xs opacity-70">(点击展开/折叠)</span>`;
} else {
messageContentWrapper.innerHTML = finalFinalAnswer ? sanitizeHTML(marked.parse(finalFinalAnswer)) : '';
if (!hasInjectedDetails) {
messageContentWrapper.classList.add('markdown-body');
}
applyHighlightingAndCopyButtons(messageContentWrapper);
}
// --- Save the complete message (including <think> tags) ---
// IMPORTANT: Even with the workaround, save the ORIGINAL AI response
// (accumulatedContent) to localStorage, *including* any <think> tags,
// so the history display remains accurate. Don't save the combinedMessage.
if (currentChatId && accumulatedContent.trim()) {
const chatData = getChatData(currentChatId);
if (chatData) {
chatData.messages.push({content: accumulatedContent, isAssistant: true});
localStorage.setItem(`chat_${currentChatId}`, JSON.stringify(chatData));
updateChatList();
}
}
currentEventSource = null;
setStreamingState(false);
messageInput.focus();
}
} catch (e) {
console.error('Error processing stream event:', e, event.data);
}
};
currentEventSource.onerror = function (error) {
// ... (SAME onerror logic as before) ...
console.error('EventSource encountered an error:', error);
streamEnded = true;
if (currentEventSource) {
currentEventSource.close();
}
const errorText = '--- 抱歉,连接中断或发生错误 ---';
messageContentWrapper = document.getElementById(messageId)?.querySelector('.message-content-wrapper');
if (messageContentWrapper) {
messageContentWrapper.querySelectorAll('.streaming-cursor').forEach(c => c.remove());
const errorP = document.createElement('p');
errorP.className = 'text-red-500 text-sm font-semibold mt-2 border-t pt-2';
errorP.textContent = errorText;
const finalAnswerDiv = messageContentWrapper.querySelector('.final-answer');
if (finalAnswerDiv) {
if (finalAnswerDiv.textContent.includes("正在处理")) finalAnswerDiv.innerHTML = '';
finalAnswerDiv.appendChild(errorP);
} else {
if (messageContentWrapper.textContent === '▋') messageContentWrapper.innerHTML = '';
messageContentWrapper.appendChild(errorP);
}
// Update summary text in case of error during thinking display
const summaryElement = messageContentWrapper.querySelector('.thinking-process summary');
if (summaryElement && summaryElement.textContent.includes("思考过程...")) {
summaryElement.innerHTML = `思考过程 <span class="text-xs opacity-70">(已中断)</span>`;
}
} else {
appendMessage(errorText, true, false);
}
currentEventSource = null;
setStreamingState(false);
messageInput.focus();
};
}