AI接入 v0.1

This commit is contained in:
2026-09-23 17:16:40 +08:00
parent ec21ba0876
commit be3fa61b8d
16 changed files with 1244 additions and 0 deletions
@@ -0,0 +1,128 @@
package com.accounting.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* AI(LLM)接入配置。
*
* <p>走 OpenAI 兼容的 chat/completions 协议,**不绑定任何供应商** ——
* DeepSeek / 通义(compatible-mode)/ 智谱 / OpenAI 都只需要改 base-url 和 model:
* <pre>
* DeepSeek https://api.deepseek.com/v1
* 通义 https://dashscope.aliyuncs.com/compatible-mode/v1
* 智谱 https://open.bigmodel.cn/api/paas/v4
* OpenAI https://api.openai.com/v1
* </pre>
* 拼接规则是 {@code base-url + "/chat/completions"}。</p>
*
* <p>api-key 只从环境变量读,application.yml 里不写默认值。key 为空时
* {@link #isReady()} 为 false,AI 接口自动降级为「未配置」提示,而不是 500。</p>
*/
@Slf4j
@Configuration
public class AiConfig {
@Value("${ai.enabled:true}")
private boolean enabled;
/** 不带 /chat/completions 后缀 */
@Value("${ai.base-url:}")
private String baseUrl;
@Value("${ai.api-key:}")
private String apiKey;
@Value("${ai.model:}")
private String model;
/** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */
@Value("${ai.max-tool-rounds:3}")
private int maxToolRounds;
/** 单次对话最多带多少条历史消息,太久远的不带 */
@Value("${ai.max-history-messages:20}")
private int maxHistoryMessages;
public boolean isReady() {
return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank();
}
/**
* 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。
* 注意**永远不打印 api-key 的内容**,只报长度。
*/
@PostConstruct
public void logConfigStatus() {
if (isReady()) {
log.info("AI 已启用:base-url={}, model={}, api-key 长度={}",
getBaseUrl(), model, apiKey.length());
} else {
log.warn("AI 未配置,助手功能将降级(status 返回 false,对话推 error 事件):"
+ "enabled={}, base-url={}, model={}, api-key={}",
enabled,
baseUrl.isBlank() ? "(空)" : baseUrl,
model.isBlank() ? "(空)" : model,
apiKey.isBlank() ? "(空)" : "已设置");
}
}
/**
* 容忍结尾多余的斜杠:否则会拼出 {@code //chat/completions},
* 网关直接 404,而且报错信息完全看不出是这个原因。
*/
public String getBaseUrl() {
return baseUrl.replaceAll("/+$", "");
}
public String getApiKey() {
return apiKey;
}
public String getModel() {
return model;
}
public int getMaxToolRounds() {
return maxToolRounds;
}
public int getMaxHistoryMessages() {
return maxHistoryMessages;
}
/**
* 出站调 LLM 用的 HttpClient。
*
* <p>刻意不复用现有的 RestTemplate —— 那个 5 秒读超时撑不住 LLM 的流式生成。
* 用 JDK 自带的 HttpClient 是为了**零新增 Maven 依赖**,流式读响应体靠
* {@code BodyHandlers.ofInputStream()}。</p>
*/
@Bean
public HttpClient aiHttpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
/**
* SSE 的工作线程池:controller 立刻返回 SseEmitter,
* 上游的流式读取和工具循环都在这个池子里跑,不占用 Tomcat 请求线程。
*/
@Bean(destroyMethod = "shutdown")
public ExecutorService aiExecutor() {
return Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "ai-chat");
t.setDaemon(true);
return t;
});
}
}