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

相关推荐
噢,我明白了几秒前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困5 分钟前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn5 分钟前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
dkbnull8 分钟前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai9 分钟前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器
顺风尿一寸11 分钟前
记一次 Spring AOP 与定时任务引发的死锁排查
java
用户2986985301411 分钟前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户772831049084014 分钟前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
用户4461394302715 分钟前
从单体到微服务:我们项目的拆分思路和踩坑记录
java
Conan在掘金21 分钟前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端