Spring AI 进阶之路04:集成 SearXNG 实现联网搜索

引子

在前三篇文章中,我们已经将一个基础的 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

修改并保存后,记得重启 SearXNGdocker容器。至此,环境准备工作全部完成。

应用集成

集成 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 与外部工具集成的新篇章。

相关推荐
码事漫谈2 分钟前
DeepSeek 3.1:技术突破与行业影响深度分析
后端
数字人直播5 分钟前
干货分享:AI 数字人直播怎么做才能适配多平台规则?
前端·后端
PineappleCoder9 分钟前
同源策略是啥?浏览器为啥拦我的跨域请求?(二)
前端·后端·node.js
爱可生开源社区11 分钟前
2025 年 8 月《GPT-5 家族 SQL 能力评测报告》发布
后端
喵手12 分钟前
Java中的垃圾回收机制(GC),你知道如何优化吗?
java·后端·java ee
年轻的麦子14 分钟前
Go 程序 OTA 子进程意外终止问题排查与解决
后端
胡gh15 分钟前
深入理解底层let,var,const;面试官:"这是大佬这是大佬"
javascript·后端·面试
YANGZHAO18 分钟前
wechatpay-java Gradle转Maven项目
后端
灵魂猎手19 分钟前
7. MyBatis 的 ResultSetHandler(一)
java·后端·源码