AI接入 v1.0

UI更新
This commit is contained in:
2026-09-24 14:27:12 +08:00
parent 90eeae51af
commit 2338ff5885
46 changed files with 2301 additions and 83 deletions
@@ -44,18 +44,38 @@ public class AiConfig {
@Value("${ai.model:}")
private String model;
/** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */
@Value("${ai.max-tool-rounds:3}")
/**
* 工具调用最多几轮。一轮内模型可以并发多个调用(实测见过一轮 3 个搜索)。
* 用满之后不再直接截断,而是再发一次**不带 tools** 的请求逼模型收尾 ——
* 否则它会留一句「我再换个关键词查查」然后回答就断了。
*/
@Value("${ai.max-tool-rounds:5}")
private int maxToolRounds;
/** 单次对话最多带多少条历史消息,太久远的不带 */
@Value("${ai.max-history-messages:20}")
private int maxHistoryMessages;
/**
* 自建 SearXNG 的地址,例如 {@code http://127.0.0.1:8888}。
* 留空 = 不注册联网搜索工具(模型不会以为自己能上网,避免瞎编)。
*/
@Value("${ai.search.base-url:}")
private String searchBaseUrl;
/** 每次联网搜索取几条结果喂给模型 */
@Value("${ai.search.max-results:5}")
private int searchMaxResults;
public boolean isReady() {
return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank();
}
/** 联网搜索是否可用。独立于 LLM 配置 —— 两个都配好了才注册搜索工具 */
public boolean isSearchReady() {
return isReady() && !searchBaseUrl.isBlank();
}
/**
* 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。
* 注意**永远不打印 api-key 的内容**,只报长度。
@@ -99,6 +119,15 @@ public class AiConfig {
return maxHistoryMessages;
}
/** 结尾斜杠容错,理由同 base-url */
public String getSearchBaseUrl() {
return searchBaseUrl.replaceAll("/+$", "");
}
public int getSearchMaxResults() {
return searchMaxResults;
}
/**
* 出站调 LLM 用的 HttpClient。
*
@@ -118,26 +118,39 @@ public class AiChatService {
/**
* 「流式调 LLM → 执行工具 → 回填 → 再调」的循环。
*
* <p>轮数用满之后**不是直接截断,而是再发一次不带 tools 的请求** ——
* 模型没法调工具,就只能基于已查到的信息给出最终答复。
* 早先的做法是直接返回,结果模型那句「我再换个关键词查查」被留在回答里、
* 后面的工具却不让跑,用户看到的回答断在半截。</p>
*
* @return assistant 的最终文本(多轮的文本会拼在一起)
*/
private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter) throws Exception {
StringBuilder full = new StringBuilder();
int rounds = 0;
int maxRounds = aiConfig.getMaxToolRounds();
while (true) {
Round round = streamOnce(llmMessages, emitter);
// 额度用完了就进入「收尾轮」:不给工具,逼模型出最终答复
boolean allowTools = rounds < maxRounds;
if (!allowTools) {
llmMessages.add(Map.of("role", "system", "content",
"(已达本次对话的工具调用上限。不要再请求调用工具,"
+ "请基于已经查到的信息直接给出最终答复;"
+ "如果信息确实不够,就如实说明还差什么。)"));
}
Round round = streamOnce(userId, llmMessages, emitter, allowTools);
full.append(round.text());
if (round.toolCalls().isEmpty()) {
// 给了工具但它没要 → 正常结束;
// 没给工具却还硬报 tool_calls → 也当结束,不能再循环了
if (round.toolCalls().isEmpty() || !allowTools) {
return full.toString();
}
rounds++;
if (rounds > aiConfig.getMaxToolRounds()) {
log.warn("AI 工具调用超过 {} 轮,强制截断", aiConfig.getMaxToolRounds());
return full.toString().isBlank() ? "(连续查询次数超出上限,请换个问法)" : full.toString();
}
// 把 assistant 的 tool_calls 原样回填,再补上每个工具的结果
llmMessages.add(Map.of(
@@ -163,13 +176,22 @@ public class AiChatService {
private record Round(String text, List<ToolCall> toolCalls, List<Object> rawToolCalls) {}
/** 流式调一次 chat/completions,边读边把文本增量推给 App */
private Round streamOnce(List<Map<String, Object>> llmMessages, SseEmitter emitter) throws Exception {
Map<String, Object> body = Map.of(
"model", aiConfig.getModel(),
"messages", llmMessages,
"tools", toolService.definitions(),
"stream", true);
/**
* 流式调一次 chat/completions,边读边把文本增量推给 App。
*
* @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用)
*/
private Round streamOnce(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter, boolean allowTools) throws Exception {
// 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", aiConfig.getModel());
body.put("messages", llmMessages);
if (allowTools) {
// 工具定义要按用户生成:记账工具的描述里带着他的真实分类清单
body.put("tools", toolService.definitions(userId));
}
body.put("stream", true);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions"))
@@ -194,8 +216,27 @@ public class AiChatService {
Map<Integer, String> names = new TreeMap<>();
Map<Integer, StringBuilder> args = new LinkedHashMap<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
/**
* 第1层:response.body() —— 最底层
* 这是 Java 11+ 的 HttpClient 返回的 HttpResponse<String> 或 HttpResponse<InputStream>
*
* 类型是 InputStream,即字节流(每次 read() 拿到 1 个或若干字节)
*
* 直接读出来的是数字(int),不适合处理中文### 第2层:new InputStreamReader(stream, UTF_8) —— 字节流 → 字符流
*
* 把字节按 UTF-8 编码转成字符
*
* 现在每次 read() 拿到的是一个 char(一个字符)
*
* 但仍然单字符读取,性能差
*
* 第3层:new BufferedReader(reader) —— 单字符 → 按行读取
* 提供 readLine() 方法,一次返回一整行字符串
* 内部维护了一个 8192 字节的缓冲区,减少底层 IO 调用次数
* 这正是 200 行 while ((line = reader.readLine()) != null) 能工作的关键
*/
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (!line.startsWith("data:")) {
@@ -205,7 +246,9 @@ public class AiChatService {
if (data.equals("[DONE]")) {
break;
}
/**
* {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1234567890}
*/
JsonNode delta = objectMapper.readTree(data).path("choices").path(0).path("delta");
String content = delta.path("content").asText(null);
@@ -214,6 +257,14 @@ public class AiChatService {
send(emitter, "delta", Map.of("content", content));
}
/**
* // 第一次 chunk
* {"tool_calls": [{"index": 0, "function": {"arguments": "{\"loc"}}]}
* // 第二次 chunk
* {"tool_calls": [{"index": 0, "function": {"arguments": "ation\":"}}]}
* // 第三次 chunk
* {"tool_calls": [{"index": 0, "function": {"arguments": "\"北京\""}}]}
*/
JsonNode toolCalls = delta.path("tool_calls");
if (toolCalls.isArray()) {
for (JsonNode tc : toolCalls) {
@@ -271,16 +322,34 @@ public class AiChatService {
LocalDate today = LocalDate.now();
YearMonth thisMonth = YearMonth.from(today);
return """
你是「强宝小助手」的 AI 助手。这是一个个人助手应用,当前用户是它唯一的用户。\
你可以通过工具查询该用户的账单统计和笔记。
今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。
大名:王洋\
小名:花花\
性别:女\
籍贯:东北\
职业:中学语文老师\
性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\
关系:你的铁杆好朋友,俩人互相喊对方**宝**\
说话小特点:\
1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\
2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\
3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\
4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\
今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。\
回答要求:
- 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。
- 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。
- 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\
原样引用占位符即可,绝对不要猜测或编造它的内容。
你能做的事(都用工具完成,别凭印象答):
- 查账 / 查笔记 / 读笔记正文:直接查。
- 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具,
缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么
(金额、分类、日期),记错了用户还得去改。
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。
""".formatted(
today,
today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA),
@@ -1,7 +1,12 @@
package com.accounting.service;
import com.accounting.config.AiConfig;
import com.accounting.dto.BillRequest;
import com.accounting.dto.BillResponse;
import com.accounting.dto.CategoryResponse;
import com.accounting.dto.NoteListItem;
import com.accounting.dto.NotePageResponse;
import com.accounting.dto.NoteRequest;
import com.accounting.dto.NoteResponse;
import com.accounting.dto.StatisticsResponse;
import com.fasterxml.jackson.databind.JsonNode;
@@ -10,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeParseException;
@@ -18,16 +24,22 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* AI 的只读工具集(OpenAI function calling 格式)。
* AI 的工具集(OpenAI function calling 格式)。
*
* <p>v1 只暴露 3 个查询工具,**没有任何写操作** —— 让模型能改数据之前,
* 必须先有「AI 生成建议 → 用户确认 → 落库」的确认流程,那是阶段 C 的事。</p>
* <p><b>读</b>:query_bills / query_notes / get_note</p>
* <p><b>写</b>:create_bill / create_note —— **直接落库,没有二次确认**
* (这是用户明确选的方式)。所以每个写工具都必须做到两件事:
* ① 关键信息不全就拒绝,并说清楚缺什么;
* ② 返回值里带上「到底写进去了什么」,让模型能逐字复述,
* 万一记错了用户才知道去改哪一条。</p>
* <p><b>联网</b>:web_search,**只在配了 SearXNG 时才注册** ——
* 没配就不给模型这个工具,免得它以为自己能上网然后瞎编。</p>
*
* <p>工具抛出的业务异常不往外抛,而是包装成 {"error": ...} 返回给模型 ——
* 让模型自己决定怎么向用户解释(比如「没有找到这篇笔记」),
* 而不是让整次对话直接失败。</p>
* <p>工具的业务性失败(参数不对、找不到分类)不往外抛,而是包成 {"error": ...}
* 回给模型,让它自己决定是问用户还是换个参数重试,而不是整次对话直接挂掉。</p>
*/
@Slf4j
@RequiredArgsConstructor
@@ -36,14 +48,27 @@ public class AiToolService {
private static final int MAX_NOTE_RESULTS = 10;
private static final int TOP_CATEGORIES = 5;
private static final int MAX_BILL_DESCRIPTION = 200;
private static final int MAX_SEARCH_QUERY = 200;
private static final int MAX_SNIPPET = 300;
private final StatisticsService statisticsService;
private final NoteService noteService;
private final BillService billService;
private final CategoryService categoryService;
private final SearxngApiClient searxngApiClient;
private final AiConfig aiConfig;
private final ObjectMapper objectMapper;
/** 工具定义(OpenAI tools 数组,直接塞进请求体) */
public List<Map<String, Object>> definitions() {
/**
* 工具定义(OpenAI tools 数组,直接塞进请求体)。
*
* <p>需要 userId 是因为记账工具的描述里要带上**这个用户真实的分类清单** ——
* 不给清单,模型多半会自己编一个「餐饮」,然后因为分类对不上而失败。</p>
*/
public List<Map<String, Object>> definitions(Long userId) {
List<Map<String, Object>> defs = new ArrayList<>();
defs.add(function("query_bills",
"查询某个月份的收支汇总:总收入、总支出、结余,以及支出最多的前几个分类",
Map.of(
@@ -51,6 +76,7 @@ public class AiToolService {
"properties", Map.of(
"month", Map.of("type", "string", "description", "月份,格式 yyyy-MM,例如 2026-09")),
"required", List.of("month"))));
defs.add(function("query_notes",
"按关键词搜索用户的笔记,返回标题和摘要(不含正文)。要引用某篇笔记的内容时,先用它拿到笔记 id,再调 get_note",
Map.of(
@@ -58,6 +84,7 @@ public class AiToolService {
"properties", Map.of(
"keyword", Map.of("type", "string", "description", "关键词,匹配标题或正文,留空表示全部笔记")),
"required", List.of())));
defs.add(function("get_note",
"读取一篇笔记的完整 Markdown 正文",
Map.of(
@@ -65,6 +92,42 @@ public class AiToolService {
"properties", Map.of(
"id", Map.of("type", "integer", "description", "笔记 ID,来自 query_notes 的结果")),
"required", List.of("id"))));
defs.add(function("create_bill",
"帮用户记一笔账(支出或收入),直接写入。可用分类 —— " + categoryPrompt(userId),
Map.of(
"type", "object",
"properties", Map.of(
"amount", Map.of("type", "number", "description", "金额,正数,例如 32.5"),
"category", Map.of("type", "string",
"description", "分类名,必须严格用描述里列出的分类"),
"direction", Map.of("type", "string", "enum", List.of("expense", "income"),
"description", "expense=支出(默认),income=收入"),
"description", Map.of("type", "string",
"description", "备注,可选,例如「买咖啡」"),
"date", Map.of("type", "string", "description", "日期 yyyy-MM-dd,默认今天")),
"required", List.of("amount", "category"))));
defs.add(function("create_note",
"把一段内容存成一篇笔记(Markdown),直接写入。用户说「帮我记一下」「记录下来」时用它",
Map.of(
"type", "object",
"properties", Map.of(
"content", Map.of("type", "string", "description", "笔记正文,Markdown 格式"),
"title", Map.of("type", "string", "description", "标题,留空则由正文首行自动提取"),
"tags", Map.of("type", "string", "description", "标签,逗号分隔,可选")),
"required", List.of("content"))));
if (aiConfig.isSearchReady()) {
defs.add(function("web_search",
"联网搜索。用于需要最新信息、实时数据,或用户明确要求上网查的时候。引用时标注来源",
Map.of(
"type", "object",
"properties", Map.of(
"query", Map.of("type", "string", "description", "搜索关键词")),
"required", List.of("query"))));
}
return defs;
}
@@ -76,14 +139,19 @@ public class AiToolService {
public String execute(Long userId, String name, String argumentsJson) {
log.info("AI tool call: user={}, tool={}, args={}", userId, name, argumentsJson);
try {
JsonNode args = objectMapper.readTree(argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson);
JsonNode args = objectMapper.readTree(
argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson);
return switch (name) {
case "query_bills" -> queryBills(userId, args);
case "query_notes" -> queryNotes(userId, args);
case "get_note" -> getNote(userId, args);
case "create_bill" -> createBill(userId, args);
case "create_note" -> createNote(userId, args);
case "web_search" -> webSearch(args);
default -> errorResult("未知工具: " + name);
};
} catch (IllegalArgumentException e) {
} catch (IllegalArgumentException | IllegalStateException e) {
// 业务性失败:原因交给模型,让它去解释或换个参数重试
return errorResult(e.getMessage());
} catch (Exception e) {
log.error("AI 工具执行失败: {}", name, e);
@@ -91,6 +159,8 @@ public class AiToolService {
}
}
// ---------------------------------------------------------------- 读
private String queryBills(Long userId, JsonNode args) throws Exception {
String month = args.path("month").asText("");
YearMonth ym;
@@ -103,7 +173,7 @@ public class AiToolService {
StatisticsResponse stats = statisticsService.getMonthlyStatistics(
userId, ym.getYear(), ym.getMonthValue());
// 只给模型支出方向、金额最大的前几个分类,省 token 也足够回答问题
// 只给支出方向、金额最大的前几个分类,省 token 也足够回答问题
List<Map<String, Object>> top = stats.getCategoryStatistics() == null ? List.of()
: stats.getCategoryStatistics().stream()
.filter(c -> c.getType() != null && c.getType() == 1)
@@ -161,6 +231,149 @@ public class AiToolService {
return objectMapper.writeValueAsString(result);
}
// ---------------------------------------------------------------- 写
private String createBill(Long userId, JsonNode args) throws Exception {
if (!args.hasNonNull("amount")) {
return errorResult("缺少金额,先问用户这笔花了多少");
}
BigDecimal amount;
try {
amount = new BigDecimal(args.get("amount").asText());
} catch (NumberFormatException e) {
return errorResult("金额格式不对,需要是数字");
}
if (amount.signum() <= 0) {
return errorResult("金额必须是正数");
}
String categoryName = args.path("category").asText("").trim();
if (categoryName.isEmpty()) {
return errorResult("缺少分类。可用的有 —— " + categoryPrompt(userId));
}
boolean income = "income".equalsIgnoreCase(args.path("direction").asText("expense"));
CategoryResponse category = resolveCategory(userId, categoryName, income ? 2 : 1);
if (category == null) {
return errorResult("找不到分类「" + categoryName + "」。可用的有 —— " + categoryPrompt(userId));
}
BillRequest request = new BillRequest();
request.setCategoryId(category.getId());
request.setAmount(amount);
request.setDescription(abbreviate(args.path("description").asText(""), MAX_BILL_DESCRIPTION));
request.setBillDate(parseDate(args.path("date").asText("")));
// type 以分类自身的为准,避免「分类是支出、direction 传成收入」这种自相矛盾
request.setType(category.getType());
BillResponse bill = billService.createBill(request, userId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("id", bill.getId());
result.put("direction", bill.getType() != null && bill.getType() == 2 ? "收入" : "支出");
result.put("category", bill.getCategoryName());
result.put("amount", bill.getAmount());
result.put("date", String.valueOf(bill.getBillDate()));
result.put("description", bill.getDescription());
return objectMapper.writeValueAsString(result);
}
private String createNote(Long userId, JsonNode args) throws Exception {
String content = args.path("content").asText("");
if (content.isBlank()) {
return errorResult("笔记正文是空的,先问用户要记什么");
}
NoteRequest request = new NoteRequest();
request.setTitle(args.path("title").asText(""));
request.setContent(content);
request.setTags(args.path("tags").asText(""));
request.setIsPinned(0);
NoteResponse note = noteService.create(userId, request);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("id", note.getId());
result.put("title", note.getTitle());
return objectMapper.writeValueAsString(result);
}
// ---------------------------------------------------------------- 联网
private String webSearch(JsonNode args) throws Exception {
String query = abbreviate(args.path("query").asText("").trim(), MAX_SEARCH_QUERY);
if (query.isEmpty()) {
return errorResult("缺少搜索关键词");
}
if (!aiConfig.isSearchReady()) {
return errorResult("联网搜索没有配置");
}
List<SearxngApiClient.SearchResult> hits =
searxngApiClient.search(query, aiConfig.getSearchMaxResults());
if (hits.isEmpty()) {
return errorResult("没搜到相关结果,可以换个关键词再试");
}
List<Map<String, Object>> results = hits.stream().map(h -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("title", h.title());
m.put("url", h.url());
m.put("snippet", abbreviate(h.content(), MAX_SNIPPET));
return m;
}).toList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("query", query);
result.put("results", results);
return objectMapper.writeValueAsString(result);
}
// ---------------------------------------------------------------- 辅助
/** 把用户真实的分类清单拼成一句话,塞进记账工具的描述里 */
private String categoryPrompt(Long userId) {
List<CategoryResponse> categories = categoryService.getCategories(userId, null);
return "支出分类:" + namesOf(categories, 1) + ";收入分类:" + namesOf(categories, 2);
}
private String namesOf(List<CategoryResponse> categories, int type) {
return categories.stream()
.filter(c -> c.getType() != null && c.getType() == type)
.map(CategoryResponse::getName)
.distinct()
.collect(Collectors.joining("、"));
}
/** 先在同方向里找,再跨方向找(用户可能把收支方向说反了) */
private CategoryResponse resolveCategory(Long userId, String name, int type) {
List<CategoryResponse> categories = categoryService.getCategories(userId, null);
for (CategoryResponse c : categories) {
if (c.getType() != null && c.getType() == type && c.getName().equals(name)) {
return c;
}
}
for (CategoryResponse c : categories) {
if (c.getName().equals(name)) {
return c;
}
}
return null;
}
private LocalDate parseDate(String text) {
if (text == null || text.isBlank()) {
return LocalDate.now();
}
try {
return LocalDate.parse(text.trim());
} catch (DateTimeParseException e) {
return LocalDate.now();
}
}
private Map<String, Object> function(String name, String description, Map<String, Object> parameters) {
return Map.of(
"type", "function",
@@ -174,7 +387,14 @@ public class AiToolService {
try {
return objectMapper.writeValueAsString(Map.of("error", message));
} catch (Exception e) {
return "{\"error\":\"" + message + "\"}";
return "{\"error\":\"工具执行出错\"}";
}
}
private String abbreviate(String s, int max) {
if (s == null) {
return "";
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -0,0 +1,91 @@
package com.accounting.service;
import com.accounting.config.AiConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* 自建 SearXNG 的搜索客户端。
*
* <p><b>两个部署前提</b>(不满足时报的错很不直观,所以在这里写清楚):</p>
* <ol>
* <li>SearXNG 的 {@code settings.yml} 里必须把 {@code json} 加进
* {@code search.formats} —— 官方镜像默认只开 html,不加就返回 403。</li>
* <li>{@code server.limiter} 最好关掉,否则 JSON 接口会被限流成 429。</li>
* </ol>
*
* <p>另外:SearXNG 本身不提供搜索能力,它只是「聚合引擎」。从国内服务器出发,
* google / duckduckgo 这些引擎大多不通,需要保留 baidu / 360search / quark 这类。</p>
*/
@Slf4j
@RequiredArgsConstructor
@Component
public class SearxngApiClient {
private final AiConfig aiConfig;
private final HttpClient aiHttpClient;
private final ObjectMapper objectMapper;
/** 一条搜索结果 */
public record SearchResult(String title, String url, String content) {
}
public List<SearchResult> search(String query, int max) {
String url = aiConfig.getSearchBaseUrl()
+ "/search?format=json&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(15))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response;
try {
response = aiHttpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (Exception e) {
log.warn("SearXNG 请求失败: {}", e.getMessage());
throw new IllegalStateException("搜索服务连不上(" + aiConfig.getSearchBaseUrl() + ")");
}
if (response.statusCode() == 403) {
throw new IllegalStateException(
"搜索服务返回 403:SearXNG 需要在 settings.yml 的 search.formats 里加上 json");
}
if (response.statusCode() != 200) {
throw new IllegalStateException("搜索服务返回 " + response.statusCode());
}
try {
JsonNode results = objectMapper.readTree(response.body()).path("results");
List<SearchResult> out = new ArrayList<>();
for (JsonNode r : results) {
if (out.size() >= max) {
break;
}
out.add(new SearchResult(
r.path("title").asText(""),
r.path("url").asText(""),
r.path("content").asText("")));
}
return out;
} catch (Exception e) {
log.warn("SearXNG 响应解析失败", e);
throw new IllegalStateException("搜索服务返回了无法解析的内容");
}
}
}