129 lines
4.1 KiB
Java
129 lines
4.1 KiB
Java
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;
|
||
});
|
||
}
|
||
}
|