AI接入 v1.0
UI更新
This commit is contained in:
@@ -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("搜索服务返回了无法解析的内容");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user