引子
在前三篇文章中,我们已经将一个基础的 LLM
逐步打造成为了一个拥有流式响应能力和私域知识库(RAG)的智能对话应用。然而,RAG 虽然强大,但它依赖的知识库是静态的、需要人工维护的。当用户问及"今天最新的股市行情如何?"或"评价一下刚刚发布的XX手机?"时,我们的 AI 依然会因为信息滞后而束手无策。要打破这个壁垒,我们就需要赋予它联网搜索的能力。

这里有一个非常关键的概念需要厘清:实现联网搜索的并非大模型本身,而是我们构建的中间件。 大模型扮演的是一个"超级大脑"的角色,它负责理解用户意图、生成搜索指令,并在获取到搜索结果后,进行归纳、总结和润色,最终以流畅的对话形式呈现给用户。
本文将通过集成元搜索引擎 SearXNG
,让我们的 Spring AI 应用真正具备实时获取网络信息的能力。
环境准备
SearXNG
是一个高度可定制的元搜索引擎,它允许你聚合来自多个搜索引擎(如 Google, Bing, Baidu)的结果,同时保护用户隐私。我们将使用 Docker 来快速部署一个我们自己的 SearXNG
实例。
1. 拉取镜像
首先,从 Docker Hub 拉取最新的 SearXNG
镜像。
shell
docker pull searxng/searxng:latest
2. 运行容器
接下来,运行容器。请注意,你需要根据自己的实际情况修改本地配置文件的映射目录。
shell
docker run -p 6080:8080 --name searxng -d --restart=always -v "D:\devolop\SearXNG:/etc/searxng" -e "BASE_URL=http://localhost:6080/" -e "INSTANCE_NAME=lee-instance" searxng/searxng:latest
命令解析:
-p 6080:8080
:将容器内部的8080
端口映射到我们宿主机的6080
端口,方便访问。--name searxng
:为容器指定一个友好的名称。-v "D:\devolop\SearXNG:/etc/searxng"
:关键一步! 将容器内的配置文件目录/etc/searxng
挂载到我们本地的D:\devolop\SearXNG
目录。这使得我们可以直接在本地修改配置文件,而无需进入容器。-e "BASE_URL=..."
:设置服务的公开访问地址。
容器成功运行后,我们的搜索引擎就已经准备就绪了。

现在,打开浏览器访问 http://localhost:6080
,就能看到 SearXNG
的主界面。

3. 配置与测试
SearXNG
的一大优势是其高度的可配置性。点击右上角的"首选项",可以自由选择要启用的搜索引擎。考虑到网络环境,建议在国内的读者开启百度、必应、搜狗等,关闭 Google,确保搜索功能稳定使用。

配置完成后,让我们来测试一下搜索功能。

重要提示: 为了让我们的 Spring Boot
应用后面能够通过 API 调用 SearXNG
,我们必须显式地允许 json
格式的输出。打开我们之前挂载到本地的配置文件(我这里是 D:\devolop\SearXNG\settings.yml
),找到 search
-> formats
,在下面添加 - json
。

修改并保存后,记得重启 SearXNG
的docker
容器。至此,环境准备工作全部完成。
应用集成
集成 SearXNG
的过程非常直接,本质上就是通过 HTTP 请求调用它的搜索 API。
1. 添加依赖与配置
首先,我们需要一个 HTTP 客户端库来帮助我们发送请求。这里选用 OkHttp
,它稳定且易于使用。
在 pom.xml
中添加依赖。
xml
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
然后,创建一个配置类,将 OkHttpClient
注册为 Spring Bean,方便在项目中注入和复用。
java
import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.concurrent.TimeUnit;
@Configuration
public class OkHttpConfig implements WebMvcConfigurer {
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
}
在 application.yml
中添加 SearXNG
服务的相关配置:
yaml
internet:
websearch:
url: http://localhost:6080/search
counts: 25 # 限制每次搜索返回给大模型的结果数量,避免上下文过长
2. 创建数据模型
为了将 SearXNG
返回的 JSON 响应映射为 Java 对象,我们需要创建对应的实体类。
java
package com.cc.bean;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class SearchResult {
private String title;
private String content;
private String url;
private Double score;
}
java
package com.cc.bean;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.List;
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class SearXNGResponse {
private String query;
private List<SearchResult> results;
}
3. 实现搜索服务
现在,我们来编写调用 SearXNG
API 的核心逻辑。
java
package com.cc.service.impl;
import cn.hutool.json.JSONUtil;
import com.cc.bean.SearXNGResponse;
import com.cc.bean.SearchResult;
import com.cc.service.SearXngService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; // 引入Slf4j
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody; // 引入ResponseBody
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
@Service
@RequiredArgsConstructor
@Slf4j // 添加Slf4j注解
public class SearXngServiceImpl implements SearXngService {
@Value("${internet.websearch.searxng.url}")
private String SEARXNG_URL;
@Value("${internet.websearch.searxng.counts}")
private Integer SEARXNG_COUNTS;
private final OkHttpClient okHttpClient;
@Override
public List<SearchResult> search(String query) {
HttpUrl url = HttpUrl.get(SEARXNG_URL)
.newBuilder()
.addQueryParameter("q", query)
.addQueryParameter("format", "json")
.build();
Request request = new Request.Builder()
.url(url)
.build();
log.info("正在向 SearXNG 发起请求: {}", url);
try (Response response = okHttpClient.newCall(request).execute()) {
// --- 核心修改:提供详细的错误信息 ---
if (!response.isSuccessful()) {
String errorBody = "无法获取响应体";
try (ResponseBody body = response.body()) {
if (body != null) {
errorBody = body.string();
}
} catch (IOException e) {
log.error("读取SearXNG错误响应体失败", e);
}
// 抛出包含状态码和响应体的详细异常
throw new RuntimeException(String.format(
"请求 SearXNG 失败。状态码: %d, URL: %s, 响应体: %s",
response.code(), url, errorBody
));
}
ResponseBody body = response.body();
if (body != null) {
String responseBody = body.string();
// 增加一个日志,方便调试返回的JSON内容
log.debug("SearXNG 响应内容: {}", responseBody);
SearXNGResponse searXNGResponse = JSONUtil.toBean(responseBody, SearXNGResponse.class);
if (searXNGResponse != null && searXNGResponse.getResults() != null) {
return dealResult(searXNGResponse.getResults());
} else {
log.warn("SearXNG 返回的JSON无法解析或结果为空。响应: {}", responseBody);
return Collections.emptyList();
}
}
} catch (IOException e) {
// 对于网络连接层面的IO异常,也提供更详细的日志
log.error("请求 SearXNG 发生网络IO异常, URL: {}", url, e);
throw new RuntimeException("请求 SearXNG 发生网络IO异常", e);
}
return Collections.emptyList();
}
private List<SearchResult> dealResult(List<SearchResult> results) {
if (results.isEmpty()) {
return Collections.emptyList();
}
// 注意:原有的 subList 和 parallelStream 结合可能有问题,如果 results 数量小于 SEARXNG_COUNTS
// 先 limit 再 sorted 更安全高效
return results.stream()
.limit(SEARXNG_COUNTS)
.sorted(Comparator.comparingDouble(SearchResult::getScore).reversed())
.toList();
}
}
4. 迭代聊天接口
现在,我们需要将"联网搜索"作为一种新的对话模式,集成到我们现有的聊天逻辑中。
首先,定义一个 ChatMode
枚举来管理所有对话模式。
java
package com.cc.enums;
public enum ChatMode {
/**
* 直接对话模式
*/
DIRECT,
/**
* 基于已上传文档的知识库模式 (RAG)
*/
KNOWLEDGE_BASE,
/**
* 联网搜索模式
*/
INTERNET_SEARCH
}
更新我们的请求实体 ChatEntity
,用 ChatMode
枚举替代之前的布尔值。
java
package com.cc.bean;
import com.cc.enums.ChatMode;
import lombok.Data;
@Data
public class ChatEntity {
private String currentUserName;
private String message;
private String botMsgId;
private ChatMode mode;
}
最后,也是最核心的一步,改造 ChatServiceImpl
,使其能够根据不同的 ChatMode
执行不同的逻辑。
java
package com.cc.service.impl;
import com.cc.bean.ChatEntity;
import com.cc.bean.SearchResult;
import com.cc.enums.ChatMode;
import com.cc.enums.SSEMsgType;
import com.cc.service.ChatService;
import com.cc.service.DocumentService;
import com.cc.service.SearXngService;
import com.cc.utils.SSEServer;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class ChatServiceImpl implements ChatService {
private final ChatClient chatClient;
@Resource
private DocumentService documentService;
@Resource
private SearXngService searXngService;
private static final String RAG_PROMPT_TEMPLATE = """
请根据下面提供的上下文知识库内容来回答用户的问题。
规则:
1. 回答时,要充分利用上下文信息,但不要在回答中直接提及"根据上下文"或"根据知识库"等词语。
2. 如果上下文中没有足够的信息来回答问题,请明确告知:"根据现有的知识,我无法回答这个问题。"
3. 你的回答应该是直接、清晰且相关的。
【上下文】
{context}
【问题】
{question}
""";
// 新增:为联网搜索模式设计的提示词模板
private static final String INTERNET_SEARCH_PROMPT_TEMPLATE = """
你现在是一个拥有实时网络搜索能力的智能助手。请根据下面提供的最新网络搜索结果来回答用户的问题。
规则:
1. 综合分析所有搜索结果,为用户提供一个全面、准确、连贯的回答。
2. 在回答中,不要直接引用"根据搜索结果...",而是自然地组织语言。
3. 如果搜索结果未能提供足够信息,请坦诚地告知用户:"根据目前的搜索结果,我无法找到关于您问题的确切信息。"
4. 你的回答应该简洁明了,直击要点。
【网络搜索结果】
{context}
【用户问题】
{question}
""";
public ChatServiceImpl(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Override
public void streamChat(ChatEntity chatEntity) {
String userId = chatEntity.getCurrentUserName();
String question = chatEntity.getMessage();
// 获取前端传递的模式,如果没有则默认为直接对话
ChatMode mode = chatEntity.getMode() != null ? chatEntity.getMode() : ChatMode.DIRECT;
Prompt prompt;
// 使用 switch 语句根据模式选择不同的逻辑
switch (mode) {
case KNOWLEDGE_BASE:
log.info("【用户: {}】正在使用【知识库模式】进行提问。", userId);
prompt = createRagPrompt(question);
break;
case INTERNET_SEARCH:
log.info("【用户: {}】正在使用【联网搜索模式】进行提问。", userId);
prompt = createInternetSearchPrompt(question);
break;
case DIRECT:
default:
log.info("【用户: {}】正在使用【直接对话模式】进行提问。", userId);
prompt = new Prompt(question);
break;
}
Flux<String> stream = chatClient.prompt(prompt).stream().content();
stream.doOnError(throwable -> {
log.error("【用户: {}】的AI流处理发生错误: {}", userId, throwable.getMessage(), throwable);
SSEServer.sendMsg(userId, "抱歉,服务出现了一点问题,请稍后再试。", SSEMsgType.FINISH);
SSEServer.close(userId);
})
.subscribe(
content -> SSEServer.sendMsg(userId, content, SSEMsgType.ADD),
error -> log.error("【用户: {}】的流订阅最终失败: {}", userId, error.getMessage()),
() -> {
log.info("【用户: {}】的流已成功结束。", userId);
SSEServer.sendMsg(userId, "done", SSEMsgType.FINISH);
SSEServer.close(userId);
}
);
}
/**
* 创建 RAG (知识库) 模式的 Prompt
*/
private Prompt createRagPrompt(String question) {
List<Document> relatedDocs = documentService.doSearch(question);
String context = "没有找到相关的知识库信息。";
if (!CollectionUtils.isEmpty(relatedDocs)) {
context = relatedDocs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n---\n"));
}
String promptContent = RAG_PROMPT_TEMPLATE
.replace("{context}", context)
.replace("{question}", question);
return new Prompt(promptContent);
}
/**
* 创建联网搜索模式的 Prompt
*/
private Prompt createInternetSearchPrompt(String question) {
// 1. 执行联网搜索
List<SearchResult> searchResults = searXngService.search(question);
String context = "未能获取到有效的网络搜索结果。";
// 2. 构建上下文
if (!CollectionUtils.isEmpty(searchResults)) {
// 将搜索结果格式化为清晰的上下文文本
context = searchResults.stream()
.map(result -> String.format("【来源标题】: %s\n【内容摘要】: %s\n【链接】: %s",
result.getTitle(),
result.getContent(),
result.getUrl()))
.collect(Collectors.joining("\n\n---\n\n"));
}
// 3. 创建提示词
String promptContent = INTERNET_SEARCH_PROMPT_TEMPLATE
.replace("{context}", context)
.replace("{question}", question);
return new Prompt(promptContent);
}
}
5. 前端页面改造
最后,我们需要改造前端页面,将原来的"知识库"开关升级为一个包含三种模式的下拉选择框。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RAG & 联网搜索 增强流式对话</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f4f7f9;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.chat-container {
width: 90%;
max-width: 800px;
height: 90vh;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-header {
background-color: #4a90e2;
color: white;
padding: 16px;
font-size: 1.2em;
text-align: center;
font-weight: bold;
}
.chat-messages {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.message {
padding: 12px 18px;
border-radius: 18px;
max-width: 75%;
line-height: 1.5;
word-wrap: break-word;
}
.user-message {
background-color: #dcf8c6;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.bot-message {
background-color: #e9e9eb;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.chat-input-area {
display: flex;
padding: 15px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
align-items: center;
}
#message-input {
flex-grow: 1;
padding: 12px;
border: 1px solid #ccc;
border-radius: 20px;
resize: none;
font-size: 1em;
margin-right: 10px;
}
#send-button {
padding: 12px 25px;
border: none;
background-color: #4a90e2;
color: white;
border-radius: 20px;
cursor: pointer;
font-size: 1em;
transition: background-color 0.3s;
flex-shrink: 0;
}
#send-button:disabled {
background-color: #a0c7ff;
cursor: not-allowed;
}
#upload-button {
width: 44px;
height: 44px;
border: 1px solid #ccc;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-right: 10px;
background-color: #fff;
transition: background-color 0.3s;
flex-shrink: 0;
}
#upload-button:hover {
background-color: #f0f0f0;
}
#upload-button svg {
width: 20px;
height: 20px;
fill: #555;
}
#upload-button.loading {
cursor: not-allowed;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
#file-input {
display: none;
}
#toast-notification {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 12px 25px;
border-radius: 8px;
color: white;
font-weight: bold;
opacity: 0;
visibility: hidden;
transition: opacity 0.5s, visibility 0.5s;
z-index: 1000;
}
#toast-notification.show {
opacity: 1;
visibility: visible;
}
#toast-notification.success {
background-color: #28a745;
}
#toast-notification.error {
background-color: #dc3545;
}
/* --- 新增:聊天模式选择器样式 --- */
.chat-mode-selector {
position: relative;
margin-right: 10px;
}
.chat-mode-selector select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 20px;
padding: 10px 30px 10px 15px;
font-size: 0.9em;
color: #333;
cursor: pointer;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007AFF%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-repeat: no-repeat;
background-position: right 10px top 50%;
background-size: .65em auto;
}
</style>
</head>
<body>
<div class="chat-container">
<div class="chat-header">RAG & 联网搜索 增强流式对话</div>
<div class="chat-messages" id="chat-messages">
<!-- 聊天消息会在这里动态添加 -->
</div>
<div id="toast-notification"></div>
<div class="chat-input-area">
<label for="file-input" id="upload-button" title="上传知识文档">
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.6 364.8c-12.8-12.8-32-12.8-44.8 0l-160 160c-12.8 12.8-12.8 32 0 44.8 12.8 12.8 32 12.8 44.8 0l102.4-102.4v300.8c0 19.2 12.8 32 32 32s32-12.8 32-32V467.2l102.4 102.4c12.8 12.8 32 12.8 44.8 0 12.8-12.8 12.8-32 0-44.8L761.6 364.8zM896 896H128V128h448c19.2 0 32-12.8 32-32s-12.8-32-32-32H128C64 64 0 128 0 192v704c0 64 64 128 128 128h768c64 0 128-64 128-128V448c0-19.2-12.8-32-32-32s-32 12.8-32 32v448z"></path></svg>
</label>
<input type="file" id="file-input" accept=".txt,.pdf,.md,.docx">
<!-- 修改:将开关替换为下拉选择框 -->
<div class="chat-mode-selector" title="选择对话模式">
<select id="chat-mode-select">
<option value="DIRECT">直接对话</option>
<option value="KNOWLEDGE_BASE">知识库</option>
<option value="INTERNET_SEARCH">联网搜索</option>
</select>
</div>
<textarea id="message-input" placeholder="输入您的问题..." rows="1"></textarea>
<button id="send-button">发送</button>
</div>
</div>
<script>
// --- DOM 元素获取 ---
const chatMessages = document.getElementById('chat-messages');
const messageInput = document.getElementById('message-input');
const sendButton = document.getElementById('send-button');
const fileInput = document.getElementById('file-input');
const uploadButton = document.getElementById('upload-button');
const toast = document.getElementById('toast-notification');
// 修改:获取新的下拉选择框元素
const chatModeSelect = document.getElementById('chat-mode-select');
// --- 核心变量 ---
const userId = 'user-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
let eventSource = null;
let currentBotMessageElement = null;
// --- 文件上传逻辑 (无变化) ---
async function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
uploadButton.classList.add('loading');
uploadButton.style.pointerEvents = 'none';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/rag/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok && result.status === 200) {
showToast(`文档 "${file.name}" 上传成功!`, 'success');
// 智能切换:上传成功后,自动切换到知识库模式
chatModeSelect.value = 'KNOWLEDGE_BASE';
updatePlaceholder();
} else {
showToast(`上传失败: ${result.msg || '未知错误'}`, 'error');
}
} catch (error) {
console.error('Upload failed:', error);
showToast('上传失败,请检查网络或联系管理员。', 'error');
} finally {
uploadButton.classList.remove('loading');
uploadButton.style.pointerEvents = 'auto';
fileInput.value = '';
}
}
// --- Toast 通知 (无变化) ---
function showToast(message, type = 'success') {
toast.textContent = message;
toast.className = 'show';
toast.classList.add(type);
setTimeout(() => {
toast.className = toast.className.replace('show', '');
}, 3000);
}
// --- SSE 连接 (无变化) ---
function connectSSE() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource(`/sse/connect?userId=${userId}`);
eventSource.addEventListener('add', (event) => {
if (!currentBotMessageElement) {
currentBotMessageElement = createMessageElement('bot-message');
chatMessages.appendChild(currentBotMessageElement);
}
if (event.data && event.data.toLowerCase() !== 'null') {
currentBotMessageElement.innerHTML += event.data.replace(/\n/g, '<br>');
}
scrollToBottom();
});
eventSource.addEventListener('finish', (event) => {
console.log('Stream finished:', event.data);
currentBotMessageElement = null;
sendButton.disabled = false;
messageInput.disabled = false;
eventSource.close();
});
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
if (currentBotMessageElement) {
currentBotMessageElement.innerHTML += '<br><strong style="color: red;">连接中断,请重试。</strong>';
}
sendButton.disabled = false;
messageInput.disabled = false;
eventSource.close();
};
}
// --- 发送消息 (核心修改) ---
async function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
displayUserMessage(message);
messageInput.value = '';
messageInput.focus();
sendButton.disabled = true;
messageInput.disabled = true;
connectSSE();
// 修改:获取当前选择的模式
const selectedMode = chatModeSelect.value;
try {
await fetch('/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
// 修改:发送包含模式信息的完整JSON对象
body: JSON.stringify({
currentUserName: userId,
message: message,
mode: selectedMode // 后端期望的字段是 'mode'
}),
});
} catch (error) {
console.error('Failed to send message:', error);
if(currentBotMessageElement) {
currentBotMessageElement.innerHTML += '<br><strong style="color: red;">消息发送失败,请检查后端服务。</strong>';
} else {
displayBotMessage('<strong style="color: red;">消息发送失败,请检查后端服务。</strong>');
}
sendButton.disabled = false;
messageInput.disabled = false;
}
}
// --- UI 辅助函数 (无变化) ---
function createMessageElement(className, htmlContent = '') {
const div = document.createElement('div');
div.className = `message ${className}`;
div.innerHTML = htmlContent;
return div;
}
function displayUserMessage(message) {
const userMessageElement = createMessageElement('user-message', message);
chatMessages.appendChild(userMessageElement);
scrollToBottom();
}
function displayBotMessage(message) {
const botMessageElement = createMessageElement('bot-message', message);
chatMessages.appendChild(botMessageElement);
scrollToBottom();
}
function scrollToBottom() {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// 修改:根据下拉框状态更新输入框提示
function updatePlaceholder() {
const selectedMode = chatModeSelect.value;
switch(selectedMode) {
case 'KNOWLEDGE_BASE':
messageInput.placeholder = '基于已上传的知识库进行提问...';
break;
case 'INTERNET_SEARCH':
messageInput.placeholder = '我将联网搜索并回答您的问题...';
break;
case 'DIRECT':
default:
messageInput.placeholder = '直接与我对话...';
break;
}
}
// --- 事件绑定 ---
sendButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
fileInput.addEventListener('change', handleFileUpload);
// 修改:为下拉框绑定change事件,以更新placeholder
chatModeSelect.addEventListener('change', updatePlaceholder);
// 初始化placeholder
updatePlaceholder();
</script>
</body>
</html>
测试
一切就绪,让我们来检验最终的成果。
测试一:普通模式 我们先在"直接对话"模式下,询问关于博主的信息。

正如预期的,模型并不知道任何关于博主的信息,因为它没有相关的训练数据。
测试二:联网搜索模式 现在,切换到"联网搜索"模式,再问一次同样的问题。

成功了!我们的应用首先通过 SearXNG
在互联网上进行了搜索,然后 Spring AI
将搜索结果作为上下文,生成了一段准确、流畅的介绍。我们的 AI 真正拥有了连接现实世界的能力!
小结
通过集成私有化部署的元搜索引擎 SearXNG,我们的 Spring AI 应用成功获得了联网搜索能力。目前应用已经形成了完整的功能体系:通用对话、私域知识问答以及实时信息获取。然而,这些功能仍然局限于对话交互这一单一形式。
为了突破这一局限,让 AI 能够与外部系统进行更深层次的交互,下一篇文章将探讨 MCP(Model Context Protocol)协议的应用,开启 AI 与外部工具集成的新篇章。