feat: support deepseek and openai-compatible providers
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 功能说明
|
## 功能说明
|
||||||
|
|
||||||
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek API 分析并生成 Excel 工作量统计文件。
|
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek 或 OpenAI 兼容 API 分析并生成 Excel 工作量统计文件。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
@@ -22,11 +22,13 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
|||||||
## 使用步骤
|
## 使用步骤
|
||||||
|
|
||||||
1. 在「SVN 日志抓取」先生成 `.md` 文件
|
1. 在「SVN 日志抓取」先生成 `.md` 文件
|
||||||
2. 在「系统设置」配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`)
|
2. 在「系统设置」选择 AI 提供商:
|
||||||
|
- `DeepSeek`:配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`)
|
||||||
|
- `OpenAI兼容`:配置 `Base URL`、`API Key`、第一阶段模型、第二阶段模型
|
||||||
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
|
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
|
||||||
4. 在「任务历史」或「产物列表」下载 `.xlsx`
|
4. 在「任务历史」或「产物列表」下载 `.xlsx`
|
||||||
|
|
||||||
## API Key 读取优先级
|
## DeepSeek API Key 读取优先级
|
||||||
|
|
||||||
1. 请求中的 `apiKey`
|
1. 请求中的 `apiKey`
|
||||||
2. 设置页保存的运行时 `apiKey`
|
2. 设置页保存的运行时 `apiKey`
|
||||||
@@ -35,5 +37,6 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 不要在源码和日志中写入真实密钥
|
- 不要在源码和日志中写入真实密钥
|
||||||
- 需要可访问 DeepSeek API 的网络环境
|
- DeepSeek 模式需要可访问 DeepSeek API 的网络环境
|
||||||
|
- OpenAI 兼容模式要求兼容服务提供 `/chat/completions` 流式接口
|
||||||
- 接口调用可能产生费用,建议控制调用频率
|
- 接口调用可能产生费用,建议控制调用频率
|
||||||
|
|||||||
+12
-3
@@ -6,7 +6,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
|
|||||||
|
|
||||||
1. SVN 参数录入与连接测试
|
1. SVN 参数录入与连接测试
|
||||||
2. 异步抓取日志并导出 Markdown
|
2. 异步抓取日志并导出 Markdown
|
||||||
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
|
3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel
|
||||||
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
|
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
|
||||||
5. 下载输出文件、配置 API Key 与输出目录
|
5. 下载输出文件、配置 API Key 与输出目录
|
||||||
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
||||||
@@ -40,7 +40,7 @@ http://localhost:18088
|
|||||||
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
|
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
|
||||||
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
|
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
|
||||||
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
|
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
|
||||||
- 系统设置:DeepSeek API Key、输出目录、默认 SVN 预置项目
|
- 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、输出目录、默认 SVN 预置项目
|
||||||
|
|
||||||
## 输出目录
|
## 输出目录
|
||||||
|
|
||||||
@@ -49,7 +49,16 @@ http://localhost:18088
|
|||||||
- Excel 输出:`outputs/excel/*.xlsx`
|
- Excel 输出:`outputs/excel/*.xlsx`
|
||||||
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
|
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
|
||||||
|
|
||||||
## API Key 读取优先级
|
## AI 提供商设置
|
||||||
|
|
||||||
|
- `DeepSeek`:沿用现有两阶段分析链路,读取 DeepSeek API Key
|
||||||
|
- `OpenAI兼容`:使用 `baseURL + apiKey + stage1Model + stage2Model` 调用兼容 `/chat/completions` 接口
|
||||||
|
- OpenAI 兼容默认值:
|
||||||
|
- `baseURL=http://127.0.0.1:5001/v1`
|
||||||
|
- `stage1Model=deepseek-v4-flash`
|
||||||
|
- `stage2Model=deepseek-v4-pro`
|
||||||
|
|
||||||
|
## DeepSeek API Key 读取优先级
|
||||||
|
|
||||||
1. AI 分析请求中的临时 `apiKey`
|
1. AI 分析请求中的临时 `apiKey`
|
||||||
2. 设置页保存的运行时 `apiKey`
|
2. 设置页保存的运行时 `apiKey`
|
||||||
|
|||||||
@@ -126,6 +126,12 @@
|
|||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>2.22.2</version>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -266,7 +266,16 @@ public class AppController {
|
|||||||
|
|
||||||
@PutMapping("/settings")
|
@PutMapping("/settings")
|
||||||
public Map<String, Object> updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException {
|
public Map<String, Object> updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException {
|
||||||
settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId());
|
settingsService.updateSettings(
|
||||||
|
request.getApiKey(),
|
||||||
|
request.getProvider(),
|
||||||
|
request.getOpenaiBaseUrl(),
|
||||||
|
request.getOpenaiApiKey(),
|
||||||
|
request.getOpenaiStageOneModel(),
|
||||||
|
request.getOpenaiStageTwoModel(),
|
||||||
|
request.getOutputDir(),
|
||||||
|
request.getDefaultSvnPresetId()
|
||||||
|
);
|
||||||
return settingsService.getSettings();
|
return settingsService.getSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ package com.svnlog.web.dto;
|
|||||||
public class SettingsUpdateRequest {
|
public class SettingsUpdateRequest {
|
||||||
|
|
||||||
private String apiKey;
|
private String apiKey;
|
||||||
|
private String provider;
|
||||||
|
private String openaiBaseUrl;
|
||||||
|
private String openaiApiKey;
|
||||||
|
private String openaiStageOneModel;
|
||||||
|
private String openaiStageTwoModel;
|
||||||
private String outputDir;
|
private String outputDir;
|
||||||
private String defaultSvnPresetId;
|
private String defaultSvnPresetId;
|
||||||
|
|
||||||
@@ -14,6 +19,46 @@ public class SettingsUpdateRequest {
|
|||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProvider(String provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiBaseUrl() {
|
||||||
|
return openaiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenaiBaseUrl(String openaiBaseUrl) {
|
||||||
|
this.openaiBaseUrl = openaiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiApiKey() {
|
||||||
|
return openaiApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenaiApiKey(String openaiApiKey) {
|
||||||
|
this.openaiApiKey = openaiApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiStageOneModel() {
|
||||||
|
return openaiStageOneModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenaiStageOneModel(String openaiStageOneModel) {
|
||||||
|
this.openaiStageOneModel = openaiStageOneModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiStageTwoModel() {
|
||||||
|
return openaiStageTwoModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOpenaiStageTwoModel(String openaiStageTwoModel) {
|
||||||
|
this.openaiStageTwoModel = openaiStageTwoModel;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOutputDir() {
|
public String getOutputDir() {
|
||||||
return outputDir;
|
return outputDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,17 +119,13 @@ public class AiWorkflowService {
|
|||||||
final Map<String, List<CommitEntry>> projectCommits = buildProjectCommitMap(content);
|
final Map<String, List<CommitEntry>> projectCommits = buildProjectCommitMap(content);
|
||||||
final Map<String, Integer> projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
|
final Map<String, Integer> projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
|
||||||
final Map<String, Integer> projectMinItems = buildProjectMinItems(projectLogCounts);
|
final Map<String, Integer> projectMinItems = buildProjectMinItems(projectLogCounts);
|
||||||
|
final AiProviderContext providerContext = resolveProviderContext(request.getApiKey());
|
||||||
|
|
||||||
context.setProgress(35, "正在请求 DeepSeek 分析");
|
context.setProgress(35, "正在请求 AI 分析");
|
||||||
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
|
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
|
||||||
? request.getPeriod().trim()
|
? request.getPeriod().trim()
|
||||||
: new SimpleDateFormat("yyyy年MM月").format(new Date());
|
: new SimpleDateFormat("yyyy年MM月").format(new Date());
|
||||||
|
|
||||||
final String apiKey = settingsService.pickActiveKey(request.getApiKey());
|
|
||||||
if (apiKey == null || apiKey.trim().isEmpty()) {
|
|
||||||
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, LinkedHashSet<String>> groupedFromAi = createGroupedItems();
|
final Map<String, LinkedHashSet<String>> groupedFromAi = createGroupedItems();
|
||||||
for (int i = 0; i < FIXED_PROJECTS.length; i++) {
|
for (int i = 0; i < FIXED_PROJECTS.length; i++) {
|
||||||
final String project = FIXED_PROJECTS[i];
|
final String project = FIXED_PROJECTS[i];
|
||||||
@@ -143,11 +139,16 @@ public class AiWorkflowService {
|
|||||||
List<ProjectSummaryItem> stageOneItems = new ArrayList<ProjectSummaryItem>();
|
List<ProjectSummaryItem> stageOneItems = new ArrayList<ProjectSummaryItem>();
|
||||||
try {
|
try {
|
||||||
final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems);
|
final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems);
|
||||||
final String stageOneResponse = callDeepSeek(apiKey, stageOnePrompt, context, DEEPSEEK_MODEL_CHAT);
|
final String stageOneResponse = callAi(providerContext, stageOnePrompt, context, 1);
|
||||||
stageOneItems = parseProjectSummaryItems(stageOneResponse, project);
|
stageOneItems = parseProjectSummaryItems(stageOneResponse, project);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
LOGGER.warn("DeepSeek chat stage failed, fallback to local compression: project={}", project, ex);
|
LOGGER.warn("{} stage one failed, fallback to local compression: project={}",
|
||||||
context.emitEvent("phase", buildEventPayload("Chat 压缩失败,已切换本地压缩: " + project));
|
providerContext.displayName,
|
||||||
|
project,
|
||||||
|
ex);
|
||||||
|
context.emitEvent("phase", buildEventPayload(
|
||||||
|
providerContext.displayName + " 压缩失败,已切换本地压缩: " + project
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stageOneItems.isEmpty()) {
|
if (stageOneItems.isEmpty()) {
|
||||||
@@ -164,12 +165,17 @@ public class AiWorkflowService {
|
|||||||
context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
|
context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
|
||||||
try {
|
try {
|
||||||
final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems);
|
final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems);
|
||||||
final String stageTwoResponse = callDeepSeek(apiKey, stageTwoPrompt, context, DEEPSEEK_MODEL_THINK);
|
final String stageTwoResponse = callAi(providerContext, stageTwoPrompt, context, 2);
|
||||||
final List<ProjectSummaryItem> stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project);
|
final List<ProjectSummaryItem> stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project);
|
||||||
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
|
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
LOGGER.warn("DeepSeek think stage failed, fallback to chat merge: project={}", project, ex);
|
LOGGER.warn("{} stage two failed, fallback to stage one merge: project={}",
|
||||||
context.emitEvent("phase", buildEventPayload("Think 精炼失败,使用 Chat 压缩结果: " + project));
|
providerContext.displayName,
|
||||||
|
project,
|
||||||
|
ex);
|
||||||
|
context.emitEvent("phase", buildEventPayload(
|
||||||
|
providerContext.displayName + " 精炼失败,使用阶段一压缩结果: " + project
|
||||||
|
));
|
||||||
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
|
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,13 +830,72 @@ public class AiWorkflowService {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String callDeepSeek(String apiKey, String prompt, TaskContext context, String model) throws IOException {
|
String normalizeChatCompletionsUrl(String baseUrl) {
|
||||||
final String modelName = model == null || model.trim().isEmpty() ? DEEPSEEK_MODEL_CHAT : model.trim();
|
final String normalized = baseUrl == null ? "" : baseUrl.trim();
|
||||||
final int primaryMaxTokens = resolvePrimaryMaxTokens(modelName);
|
if (normalized.isEmpty()) {
|
||||||
final int retryMaxTokens = resolveRetryMaxTokens(modelName);
|
return "";
|
||||||
|
}
|
||||||
|
String value = normalized;
|
||||||
|
while (value.endsWith("/")) {
|
||||||
|
value = value.substring(0, value.length() - 1);
|
||||||
|
}
|
||||||
|
if (value.endsWith("/chat/completions")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value + "/chat/completions";
|
||||||
|
}
|
||||||
|
|
||||||
|
AiProviderContext resolveProviderContext(String requestApiKey) {
|
||||||
|
final String provider = settingsService.getProvider();
|
||||||
|
if (SettingsService.PROVIDER_OPENAI_COMPATIBLE.equals(provider)) {
|
||||||
|
final String baseUrl = settingsService.getOpenaiBaseUrl();
|
||||||
|
final String apiKey = settingsService.getOpenaiApiKey();
|
||||||
|
if (baseUrl == null || baseUrl.trim().isEmpty()) {
|
||||||
|
throw new IllegalStateException("未配置 OpenAI兼容 Base URL(请先在系统设置中保存)");
|
||||||
|
}
|
||||||
|
if (apiKey == null || apiKey.trim().isEmpty()) {
|
||||||
|
throw new IllegalStateException("未配置 OpenAI兼容 API Key(请先在系统设置中保存)");
|
||||||
|
}
|
||||||
|
return new AiProviderContext(
|
||||||
|
provider,
|
||||||
|
"OpenAI兼容",
|
||||||
|
normalizeChatCompletionsUrl(baseUrl),
|
||||||
|
apiKey.trim(),
|
||||||
|
settingsService.getOpenaiStageOneModel(),
|
||||||
|
settingsService.getOpenaiStageTwoModel()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String apiKey = settingsService.pickActiveKey(requestApiKey);
|
||||||
|
if (apiKey == null || apiKey.trim().isEmpty()) {
|
||||||
|
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
|
||||||
|
}
|
||||||
|
return new AiProviderContext(
|
||||||
|
SettingsService.PROVIDER_DEEPSEEK,
|
||||||
|
"DeepSeek",
|
||||||
|
DEEPSEEK_API_URL,
|
||||||
|
apiKey.trim(),
|
||||||
|
DEEPSEEK_MODEL_CHAT,
|
||||||
|
DEEPSEEK_MODEL_THINK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String callAi(AiProviderContext providerContext,
|
||||||
|
String prompt,
|
||||||
|
TaskContext context,
|
||||||
|
int stageNumber) throws IOException {
|
||||||
|
final String modelName = stageNumber == 2
|
||||||
|
? providerContext.stageTwoModel
|
||||||
|
: providerContext.stageOneModel;
|
||||||
|
final int primaryMaxTokens = stageNumber == 2
|
||||||
|
? DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY
|
||||||
|
: DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
|
||||||
|
final int retryMaxTokens = stageNumber == 2
|
||||||
|
? DEEPSEEK_REASONER_MAX_TOKENS_RETRY
|
||||||
|
: DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
|
||||||
try {
|
try {
|
||||||
final DeepSeekStreamResult primary = retrySupport.execute(
|
final AiStreamResult primary = retrySupport.execute(
|
||||||
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, primaryMaxTokens),
|
() -> callAiOnce(providerContext, prompt, context, modelName, primaryMaxTokens),
|
||||||
3,
|
3,
|
||||||
1000L
|
1000L
|
||||||
);
|
);
|
||||||
@@ -845,16 +910,17 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.emitEvent("phase", buildEventPayload(
|
context.emitEvent("phase", buildEventPayload(
|
||||||
"DeepSeek(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ")"
|
providerContext.displayName
|
||||||
|
+ "(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ")"
|
||||||
));
|
));
|
||||||
final DeepSeekStreamResult retried = retrySupport.execute(
|
final AiStreamResult retried = retrySupport.execute(
|
||||||
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, retryMaxTokens),
|
() -> callAiOnce(providerContext, prompt, context, modelName, retryMaxTokens),
|
||||||
2,
|
2,
|
||||||
1200L
|
1200L
|
||||||
);
|
);
|
||||||
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
|
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"DeepSeek 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
|
providerContext.displayName + " 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return retried.answer;
|
return retried.answer;
|
||||||
@@ -867,21 +933,7 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int resolvePrimaryMaxTokens(String modelName) {
|
private AiStreamResult callAiOnce(AiProviderContext providerContext,
|
||||||
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
|
|
||||||
return DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY;
|
|
||||||
}
|
|
||||||
return DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int resolveRetryMaxTokens(String modelName) {
|
|
||||||
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
|
|
||||||
return DEEPSEEK_REASONER_MAX_TOKENS_RETRY;
|
|
||||||
}
|
|
||||||
return DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DeepSeekStreamResult callDeepSeekOnce(String apiKey,
|
|
||||||
String prompt,
|
String prompt,
|
||||||
TaskContext context,
|
TaskContext context,
|
||||||
String model,
|
String model,
|
||||||
@@ -906,8 +958,8 @@ public class AiWorkflowService {
|
|||||||
body.add("stream_options", streamOptions);
|
body.add("stream_options", streamOptions);
|
||||||
|
|
||||||
final Request request = new Request.Builder()
|
final Request request = new Request.Builder()
|
||||||
.url(DEEPSEEK_API_URL)
|
.url(providerContext.apiUrl)
|
||||||
.addHeader("Authorization", "Bearer " + apiKey)
|
.addHeader("Authorization", "Bearer " + providerContext.apiKey)
|
||||||
.addHeader("Content-Type", "application/json")
|
.addHeader("Content-Type", "application/json")
|
||||||
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
||||||
.build();
|
.build();
|
||||||
@@ -918,20 +970,27 @@ public class AiWorkflowService {
|
|||||||
if (response.body() != null) {
|
if (response.body() != null) {
|
||||||
errorBody = response.body().string();
|
errorBody = response.body().string();
|
||||||
}
|
}
|
||||||
String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
|
String detail = providerContext.displayName + " API 调用失败: " + response.code() + " " + errorBody;
|
||||||
if (response.code() == 429 || response.code() >= 500) {
|
if (response.code() == 429 || response.code() >= 500) {
|
||||||
throw new RetrySupport.RetryableException(detail);
|
throw new RetrySupport.RetryableException(detail);
|
||||||
}
|
}
|
||||||
throw new IllegalStateException(detail);
|
throw new IllegalStateException(detail);
|
||||||
}
|
}
|
||||||
if (response.body() == null) {
|
if (response.body() == null) {
|
||||||
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
throw new RetrySupport.RetryableException(providerContext.displayName + " API 返回空响应体");
|
||||||
|
}
|
||||||
|
final okhttp3.ResponseBody responseBody = response.body();
|
||||||
|
return readStreamingResponse(responseBody.source(), context, providerContext, model, maxTokens);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AiStreamResult readStreamingResponse(okio.BufferedSource source,
|
||||||
|
TaskContext context,
|
||||||
|
AiProviderContext providerContext,
|
||||||
|
String model,
|
||||||
|
int maxTokens) throws Exception {
|
||||||
final StringBuilder answerBuilder = new StringBuilder();
|
final StringBuilder answerBuilder = new StringBuilder();
|
||||||
final StringBuilder reasoningBuilder = new StringBuilder();
|
final StringBuilder reasoningBuilder = new StringBuilder();
|
||||||
final okhttp3.ResponseBody responseBody = response.body();
|
|
||||||
final okio.BufferedSource source = responseBody.source();
|
|
||||||
String finishReason = "";
|
String finishReason = "";
|
||||||
int reasoningDeltaCount = 0;
|
int reasoningDeltaCount = 0;
|
||||||
int answerDeltaCount = 0;
|
int answerDeltaCount = 0;
|
||||||
@@ -940,7 +999,7 @@ public class AiWorkflowService {
|
|||||||
Long usageTotalTokens = null;
|
Long usageTotalTokens = null;
|
||||||
String finalMessageContent = "";
|
String finalMessageContent = "";
|
||||||
|
|
||||||
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
|
context.emitEvent("phase", buildEventPayload("正在流式接收 " + providerContext.displayName + " 输出"));
|
||||||
while (!source.exhausted()) {
|
while (!source.exhausted()) {
|
||||||
final String line = source.readUtf8Line();
|
final String line = source.readUtf8Line();
|
||||||
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
||||||
@@ -1009,7 +1068,7 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
||||||
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
|
throw new RetrySupport.RetryableException(providerContext.displayName + " 资源不足,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
String answer = answerBuilder.toString().trim();
|
String answer = answerBuilder.toString().trim();
|
||||||
@@ -1018,7 +1077,7 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
if (answer.isEmpty()) {
|
if (answer.isEmpty()) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"DeepSeek 未返回有效 content 内容"
|
providerContext.displayName + " 未返回有效 content 内容"
|
||||||
+ " | stage_model=" + model
|
+ " | stage_model=" + model
|
||||||
+ " | api_model=" + model
|
+ " | api_model=" + model
|
||||||
+ " | finish_reason=" + finishReason
|
+ " | finish_reason=" + finishReason
|
||||||
@@ -1030,15 +1089,15 @@ public class AiWorkflowService {
|
|||||||
context.updateAiOutput(reasoningBuilder.toString(), answer);
|
context.updateAiOutput(reasoningBuilder.toString(), answer);
|
||||||
context.setAiStreamStatus("DONE");
|
context.setAiStreamStatus("DONE");
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"DeepSeek stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
|
"{} stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
|
||||||
|
providerContext.displayName,
|
||||||
model,
|
model,
|
||||||
reasoningDeltaCount,
|
reasoningDeltaCount,
|
||||||
answerDeltaCount,
|
answerDeltaCount,
|
||||||
finishReason,
|
finishReason,
|
||||||
maxTokens
|
maxTokens
|
||||||
);
|
);
|
||||||
return new DeepSeekStreamResult(answer, finishReason);
|
return new AiStreamResult(answer, finishReason);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isValidJsonObjectText(String text) {
|
private boolean isValidJsonObjectText(String text) {
|
||||||
@@ -1073,14 +1132,61 @@ public class AiWorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class DeepSeekStreamResult {
|
static final class AiProviderContext {
|
||||||
|
private final String provider;
|
||||||
|
private final String displayName;
|
||||||
|
private final String apiUrl;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String stageOneModel;
|
||||||
|
private final String stageTwoModel;
|
||||||
|
|
||||||
|
private AiProviderContext(String provider,
|
||||||
|
String displayName,
|
||||||
|
String apiUrl,
|
||||||
|
String apiKey,
|
||||||
|
String stageOneModel,
|
||||||
|
String stageTwoModel) {
|
||||||
|
this.provider = provider == null ? "" : provider.trim();
|
||||||
|
this.displayName = displayName == null ? "" : displayName.trim();
|
||||||
|
this.apiUrl = apiUrl == null ? "" : apiUrl.trim();
|
||||||
|
this.apiKey = apiKey == null ? "" : apiKey.trim();
|
||||||
|
this.stageOneModel = stageOneModel == null ? "" : stageOneModel.trim();
|
||||||
|
this.stageTwoModel = stageTwoModel == null ? "" : stageTwoModel.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String getProvider() {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getApiUrl() {
|
||||||
|
return apiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getStageOneModel() {
|
||||||
|
return stageOneModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getStageTwoModel() {
|
||||||
|
return stageTwoModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class AiStreamResult {
|
||||||
private final String answer;
|
private final String answer;
|
||||||
private final String finishReason;
|
private final String finishReason;
|
||||||
|
|
||||||
private DeepSeekStreamResult(String answer, String finishReason) {
|
private AiStreamResult(String answer, String finishReason) {
|
||||||
this.answer = answer == null ? "" : answer;
|
this.answer = answer == null ? "" : answer;
|
||||||
this.finishReason = finishReason == null ? "" : finishReason;
|
this.finishReason = finishReason == null ? "" : finishReason;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getAnswer() {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
String getFinishReason() {
|
||||||
|
return finishReason;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildTextPayload(String text) {
|
private Map<String, Object> buildTextPayload(String text) {
|
||||||
|
|||||||
@@ -8,18 +8,35 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SettingsService {
|
public class SettingsService {
|
||||||
|
public static final String PROVIDER_DEEPSEEK = "deepseek";
|
||||||
|
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
|
||||||
|
|
||||||
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
|
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
|
||||||
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
|
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
|
||||||
|
private static final String DEFAULT_OPENAI_BASE_URL = "http://127.0.0.1:5001/v1";
|
||||||
|
private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed";
|
||||||
|
private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash";
|
||||||
|
private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro";
|
||||||
|
|
||||||
private final OutputFileService outputFileService;
|
private final OutputFileService outputFileService;
|
||||||
private final SvnPresetService svnPresetService;
|
private final SvnPresetService svnPresetService;
|
||||||
private volatile String runtimeApiKey;
|
private volatile String runtimeApiKey;
|
||||||
|
private volatile String runtimeProvider;
|
||||||
|
private volatile String runtimeOpenaiBaseUrl;
|
||||||
|
private volatile String runtimeOpenaiApiKey;
|
||||||
|
private volatile String runtimeOpenaiStageOneModel;
|
||||||
|
private volatile String runtimeOpenaiStageTwoModel;
|
||||||
private volatile String defaultSvnPresetId;
|
private volatile String defaultSvnPresetId;
|
||||||
|
|
||||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||||
this.outputFileService = outputFileService;
|
this.outputFileService = outputFileService;
|
||||||
this.svnPresetService = svnPresetService;
|
this.svnPresetService = svnPresetService;
|
||||||
this.runtimeApiKey = initStartupApiKey();
|
this.runtimeApiKey = initStartupApiKey();
|
||||||
|
this.runtimeProvider = PROVIDER_DEEPSEEK;
|
||||||
|
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
|
||||||
|
this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
|
||||||
|
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
|
||||||
|
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
|
||||||
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
|
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,17 +45,43 @@ public class SettingsService {
|
|||||||
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
||||||
final String activeKey = pickActiveKey(null);
|
final String activeKey = pickActiveKey(null);
|
||||||
|
|
||||||
|
result.put("provider", getProvider());
|
||||||
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
||||||
result.put("apiKeySource", detectApiKeySource(envKey));
|
result.put("apiKeySource", detectApiKeySource(envKey));
|
||||||
|
result.put("openaiBaseUrl", getOpenaiBaseUrl());
|
||||||
|
result.put("openaiApiKey", getOpenaiApiKey());
|
||||||
|
result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey()));
|
||||||
|
result.put("openaiStageOneModel", getOpenaiStageOneModel());
|
||||||
|
result.put("openaiStageTwoModel", getOpenaiStageTwoModel());
|
||||||
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
result.put("outputDir", outputFileService.getOutputRoot().toString());
|
||||||
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateSettings(String apiKey, String outputDir, String newDefaultSvnPresetId) {
|
public void updateSettings(String apiKey,
|
||||||
|
String provider,
|
||||||
|
String openaiBaseUrl,
|
||||||
|
String openaiApiKey,
|
||||||
|
String openaiStageOneModel,
|
||||||
|
String openaiStageTwoModel,
|
||||||
|
String outputDir,
|
||||||
|
String newDefaultSvnPresetId) {
|
||||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||||
this.runtimeApiKey = apiKey.trim();
|
this.runtimeApiKey = apiKey.trim();
|
||||||
}
|
}
|
||||||
|
this.runtimeProvider = normalizeProvider(provider);
|
||||||
|
if (openaiBaseUrl != null && !openaiBaseUrl.trim().isEmpty()) {
|
||||||
|
this.runtimeOpenaiBaseUrl = openaiBaseUrl.trim();
|
||||||
|
}
|
||||||
|
if (openaiApiKey != null && !openaiApiKey.trim().isEmpty()) {
|
||||||
|
this.runtimeOpenaiApiKey = openaiApiKey.trim();
|
||||||
|
}
|
||||||
|
if (openaiStageOneModel != null && !openaiStageOneModel.trim().isEmpty()) {
|
||||||
|
this.runtimeOpenaiStageOneModel = openaiStageOneModel.trim();
|
||||||
|
}
|
||||||
|
if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) {
|
||||||
|
this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim();
|
||||||
|
}
|
||||||
if (outputDir != null && !outputDir.trim().isEmpty()) {
|
if (outputDir != null && !outputDir.trim().isEmpty()) {
|
||||||
outputFileService.setOutputRoot(outputDir);
|
outputFileService.setOutputRoot(outputDir);
|
||||||
}
|
}
|
||||||
@@ -92,4 +135,44 @@ public class SettingsService {
|
|||||||
}
|
}
|
||||||
return svnPresetService.firstPresetId();
|
return svnPresetService.firstPresetId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return normalizeProvider(runtimeProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiBaseUrl() {
|
||||||
|
return trimOrDefault(runtimeOpenaiBaseUrl, DEFAULT_OPENAI_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiApiKey() {
|
||||||
|
return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiStageOneModel() {
|
||||||
|
return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOpenaiStageTwoModel() {
|
||||||
|
return trimOrDefault(runtimeOpenaiStageTwoModel, DEFAULT_OPENAI_STAGE_TWO_MODEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeProvider(String provider) {
|
||||||
|
if (PROVIDER_OPENAI_COMPATIBLE.equalsIgnoreCase(trim(provider))) {
|
||||||
|
return PROVIDER_OPENAI_COMPATIBLE;
|
||||||
|
}
|
||||||
|
return PROVIDER_DEEPSEEK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimOrDefault(String value, String defaultValue) {
|
||||||
|
final String trimmed = trim(value);
|
||||||
|
return trimmed.isEmpty() ? defaultValue : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isConfigured(String value) {
|
||||||
|
return value != null && !value.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ function bindForms() {
|
|||||||
|
|
||||||
const settingsForm = document.querySelector("#settings-form");
|
const settingsForm = document.querySelector("#settings-form");
|
||||||
settingsForm.addEventListener("submit", onSaveSettings);
|
settingsForm.addEventListener("submit", onSaveSettings);
|
||||||
|
const settingsProvider = document.querySelector("#settings-provider");
|
||||||
|
if (settingsProvider) {
|
||||||
|
settingsProvider.addEventListener("change", () => updateSettingsProviderUI(settingsProvider.value));
|
||||||
|
}
|
||||||
|
|
||||||
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
const taskFilterBtn = document.querySelector("#btn-task-filter");
|
||||||
if (taskFilterBtn) {
|
if (taskFilterBtn) {
|
||||||
@@ -870,6 +874,14 @@ function renderFileTable() {
|
|||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch("/api/settings");
|
const data = await apiFetch("/api/settings");
|
||||||
|
document.querySelector("#settings-form [name='provider']").value = data.provider || "deepseek";
|
||||||
|
document.querySelector("#settings-form [name='apiKey']").value = "";
|
||||||
|
document.querySelector("#settings-form [name='openaiBaseUrl']").value = data.openaiBaseUrl || "";
|
||||||
|
document.querySelector("#settings-form [name='openaiApiKey']").value = data.openaiApiKey || "";
|
||||||
|
document.querySelector("#settings-form [name='openaiStageOneModel']").value =
|
||||||
|
data.openaiStageOneModel || "deepseek-v4-flash";
|
||||||
|
document.querySelector("#settings-form [name='openaiStageTwoModel']").value =
|
||||||
|
data.openaiStageTwoModel || "deepseek-v4-pro";
|
||||||
document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
|
document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
|
||||||
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
||||||
const settingsPreset = document.querySelector("#settings-default-preset");
|
const settingsPreset = document.querySelector("#settings-default-preset");
|
||||||
@@ -877,7 +889,8 @@ async function loadSettings() {
|
|||||||
settingsPreset.value = state.defaultPresetId;
|
settingsPreset.value = state.defaultPresetId;
|
||||||
}
|
}
|
||||||
applyPresetToSvnForm(state.defaultPresetId);
|
applyPresetToSvnForm(state.defaultPresetId);
|
||||||
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
|
updateSettingsProviderUI(data.provider || "deepseek");
|
||||||
|
renderSettingsState(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
@@ -895,7 +908,8 @@ async function onSaveSettings(event) {
|
|||||||
});
|
});
|
||||||
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
||||||
applyPresetToSvnForm(state.defaultPresetId);
|
applyPresetToSvnForm(state.defaultPresetId);
|
||||||
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
|
updateSettingsProviderUI(data.provider || "deepseek");
|
||||||
|
renderSettingsState(data);
|
||||||
toast("设置保存成功");
|
toast("设置保存成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
@@ -909,6 +923,29 @@ function readForm(form) {
|
|||||||
return Object.fromEntries(data.entries());
|
return Object.fromEntries(data.entries());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSettingsProviderUI(provider) {
|
||||||
|
const isOpenAiCompatible = provider === "openai-compatible";
|
||||||
|
const groupedSection = document.querySelector("#openai-settings-group");
|
||||||
|
if (groupedSection) {
|
||||||
|
groupedSection.hidden = !isOpenAiCompatible;
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#openai-api-key-group, #openai-stage-one-group, #openai-stage-two-group").forEach((node) => {
|
||||||
|
node.hidden = !isOpenAiCompatible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSettingsState(data) {
|
||||||
|
const stateEl = document.querySelector("#settings-state");
|
||||||
|
if (!stateEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((data.provider || "deepseek") === "openai-compatible") {
|
||||||
|
stateEl.textContent = `当前提供商:OpenAI兼容 | Base URL:${data.openaiBaseUrl || "(未配置)"} | API Key:${data.openaiApiKeyConfigured ? "已配置" : "未配置"} | Stage1:${data.openaiStageOneModel || "-"} | Stage2:${data.openaiStageTwoModel || "-"}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stateEl.textContent = `当前提供商:DeepSeek | API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
|
||||||
|
}
|
||||||
|
|
||||||
function setLoading(button, loading) {
|
function setLoading(button, loading) {
|
||||||
if (!button) {
|
if (!button) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -345,11 +345,45 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="settings-form" class="form-layout">
|
<form id="settings-form" class="form-layout">
|
||||||
|
<div class="form-group form-group-full">
|
||||||
|
<label for="settings-provider" class="form-label">AI 提供商</label>
|
||||||
|
<select name="provider" id="settings-provider" class="form-select">
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="openai-compatible">OpenAI兼容</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group form-group-full">
|
<div class="form-group form-group-full">
|
||||||
<label for="api-key-input" class="form-label">DeepSeek API Key</label>
|
<label for="api-key-input" class="form-label">DeepSeek API Key</label>
|
||||||
<input type="password" name="apiKey" id="api-key-input" class="form-input" placeholder="设置后将保存在当前进程内存">
|
<input type="password" name="apiKey" id="api-key-input" class="form-input" placeholder="设置后将保存在当前进程内存">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-group-full" id="openai-settings-group" hidden>
|
||||||
|
<label for="openai-base-url" class="form-label">OpenAI兼容 Base URL</label>
|
||||||
|
<input type="text" name="openaiBaseUrl" id="openai-base-url" class="form-input" placeholder="例如 http://127.0.0.1:5001/v1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-group-full" id="openai-api-key-group" hidden>
|
||||||
|
<label for="openai-api-key" class="form-label">OpenAI兼容 API Key</label>
|
||||||
|
<input type="password" name="openaiApiKey" id="openai-api-key" class="form-input" placeholder="设置后将保存在当前进程内存">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="openai-stage-one-group" hidden>
|
||||||
|
<label for="openai-stage-one-model" class="form-label">第一阶段模型</label>
|
||||||
|
<select name="openaiStageOneModel" id="openai-stage-one-model" class="form-select">
|
||||||
|
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
|
||||||
|
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="openai-stage-two-group" hidden>
|
||||||
|
<label for="openai-stage-two-model" class="form-label">第二阶段模型</label>
|
||||||
|
<select name="openaiStageTwoModel" id="openai-stage-two-model" class="form-select">
|
||||||
|
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
|
||||||
|
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group form-group-full">
|
<div class="form-group form-group-full">
|
||||||
<label for="default-preset" class="form-label">默认 SVN 项目</label>
|
<label for="default-preset" class="form-label">默认 SVN 项目</label>
|
||||||
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
|
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
|
||||||
|
|||||||
@@ -184,9 +184,35 @@
|
|||||||
<article class="card form-card">
|
<article class="card form-card">
|
||||||
<h3>系统设置</h3>
|
<h3>系统设置</h3>
|
||||||
<form id="settings-form" class="form-grid">
|
<form id="settings-form" class="form-grid">
|
||||||
|
<label class="span-2">AI 提供商
|
||||||
|
<select name="provider" id="settings-provider">
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="openai-compatible">OpenAI兼容</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="span-2">DeepSeek API Key
|
<label class="span-2">DeepSeek API Key
|
||||||
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
|
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
|
||||||
</label>
|
</label>
|
||||||
|
<div class="span-2" id="openai-settings-group" hidden>
|
||||||
|
<label class="span-2">OpenAI兼容 Base URL
|
||||||
|
<input type="text" name="openaiBaseUrl" placeholder="例如 http://127.0.0.1:5001/v1">
|
||||||
|
</label>
|
||||||
|
<label class="span-2">OpenAI兼容 API Key
|
||||||
|
<input type="password" name="openaiApiKey" placeholder="设置后将保存在当前进程内存">
|
||||||
|
</label>
|
||||||
|
<label>第一阶段模型
|
||||||
|
<select name="openaiStageOneModel">
|
||||||
|
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
|
||||||
|
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>第二阶段模型
|
||||||
|
<select name="openaiStageTwoModel">
|
||||||
|
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
|
||||||
|
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label class="span-2">默认 SVN 项目
|
<label class="span-2">默认 SVN 项目
|
||||||
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
|
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
|
||||||
</label>
|
</label>
|
||||||
@@ -205,6 +231,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/app.js?v=20260407_1811" defer></script>
|
<script src="/app.js?v=20260429_1808" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import com.svnlog.web.model.TaskInfo;
|
||||||
|
import com.svnlog.web.model.TaskStatus;
|
||||||
|
|
||||||
|
import okio.Buffer;
|
||||||
|
|
||||||
|
class AiWorkflowServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldResolveDeepSeekProviderByDefault() {
|
||||||
|
final AiWorkflowService service = new AiWorkflowService(
|
||||||
|
buildOutputFileService(),
|
||||||
|
new SettingsService(buildOutputFileService(), new SvnPresetService()),
|
||||||
|
new AiInputValidator()
|
||||||
|
);
|
||||||
|
|
||||||
|
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
|
||||||
|
|
||||||
|
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
|
||||||
|
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
|
||||||
|
Assertions.assertEquals("deepseek-reasoner", context.getStageTwoModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldResolveOpenAiCompatibleModelsAndUrl() {
|
||||||
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
|
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
|
||||||
|
settingsService.updateSettings(
|
||||||
|
null,
|
||||||
|
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
||||||
|
"http://127.0.0.1:5001/v1/",
|
||||||
|
"sk-openai-test",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
|
||||||
|
|
||||||
|
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
|
||||||
|
|
||||||
|
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, context.getProvider());
|
||||||
|
Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl());
|
||||||
|
Assertions.assertEquals("deepseek-v4-flash", context.getStageOneModel());
|
||||||
|
Assertions.assertEquals("deepseek-v4-pro", context.getStageTwoModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() {
|
||||||
|
final AiWorkflowService service = new AiWorkflowService(
|
||||||
|
buildOutputFileService(),
|
||||||
|
new StubSettingsService(buildOutputFileService(), " ", "sk-openai-test"),
|
||||||
|
new AiInputValidator()
|
||||||
|
);
|
||||||
|
|
||||||
|
final IllegalStateException error = Assertions.assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> service.resolveProviderContext(null)
|
||||||
|
);
|
||||||
|
|
||||||
|
Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
|
||||||
|
final OutputFileService outputFileService = buildOutputFileService();
|
||||||
|
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
|
||||||
|
settingsService.updateSettings(
|
||||||
|
null,
|
||||||
|
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
||||||
|
"http://127.0.0.1:5001/v1",
|
||||||
|
"sk-openai-test",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
|
||||||
|
final AiWorkflowService.AiProviderContext providerContext = service.resolveProviderContext(null);
|
||||||
|
final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null);
|
||||||
|
final Buffer buffer = new Buffer()
|
||||||
|
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n")
|
||||||
|
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"summary\\\":\\\"done\\\"}\"}}]}\n")
|
||||||
|
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n")
|
||||||
|
.writeUtf8("data: [DONE]\n");
|
||||||
|
|
||||||
|
final AiWorkflowService.AiStreamResult result = service.readStreamingResponse(
|
||||||
|
buffer,
|
||||||
|
taskContext,
|
||||||
|
providerContext,
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
|
||||||
|
Assertions.assertEquals("{\"items\":[{\"summary\":\"done\"}]}", result.getAnswer());
|
||||||
|
Assertions.assertEquals("stop", result.getFinishReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private OutputFileService buildOutputFileService() {
|
||||||
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
|
return outputFileService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskInfo buildTaskInfo() {
|
||||||
|
final TaskInfo taskInfo = new TaskInfo();
|
||||||
|
taskInfo.setTaskId("task-1");
|
||||||
|
taskInfo.setStatus(TaskStatus.RUNNING);
|
||||||
|
taskInfo.setCreatedAt(Instant.now());
|
||||||
|
taskInfo.setUpdatedAt(Instant.now());
|
||||||
|
return taskInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class StubSettingsService extends SettingsService {
|
||||||
|
private final String openaiBaseUrl;
|
||||||
|
private final String openaiApiKey;
|
||||||
|
|
||||||
|
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
|
||||||
|
super(outputFileService, new SvnPresetService());
|
||||||
|
this.openaiBaseUrl = openaiBaseUrl;
|
||||||
|
this.openaiApiKey = openaiApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getProvider() {
|
||||||
|
return SettingsService.PROVIDER_OPENAI_COMPATIBLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getOpenaiBaseUrl() {
|
||||||
|
return openaiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getOpenaiApiKey() {
|
||||||
|
return openaiApiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,40 @@
|
|||||||
package com.svnlog.web.service;
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Assertions;
|
import org.junit.jupiter.api.Assertions;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mockito;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
public class HealthServiceTest {
|
class HealthServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
private TaskService taskService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (taskService != null) {
|
||||||
|
taskService.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void shouldReturnDetailedHealthWhenDependenciesAvailable() throws Exception {
|
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
|
||||||
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
SettingsService settingsService = Mockito.mock(SettingsService.class);
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
TaskService taskService = Mockito.mock(TaskService.class);
|
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
|
||||||
|
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
|
||||||
|
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
|
||||||
|
|
||||||
Path outputDir = Files.createTempDirectory("health-service-test");
|
final Map<String, Object> details = healthService.detailedHealth();
|
||||||
Mockito.when(outputFileService.getOutputRoot()).thenReturn(outputDir);
|
|
||||||
|
|
||||||
Map<String, Object> settings = new HashMap<String, Object>();
|
|
||||||
settings.put("apiKeyConfigured", true);
|
|
||||||
Mockito.when(settingsService.getSettings()).thenReturn(settings);
|
|
||||||
Mockito.when(taskService.getTasks()).thenReturn(new java.util.ArrayList<>());
|
|
||||||
|
|
||||||
HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
|
|
||||||
Map<String, Object> details = healthService.detailedHealth();
|
|
||||||
|
|
||||||
Assertions.assertEquals("ok", details.get("status"));
|
Assertions.assertEquals("ok", details.get("status"));
|
||||||
Assertions.assertEquals(true, details.get("outputDirWritable"));
|
Assertions.assertTrue(Boolean.TRUE.equals(details.get("outputDirWritable")));
|
||||||
Assertions.assertEquals(true, details.get("apiKeyConfigured"));
|
Assertions.assertTrue(details.containsKey("apiKeyConfigured"));
|
||||||
Assertions.assertEquals(0, details.get("taskTotal"));
|
Assertions.assertEquals(Integer.valueOf(0), details.get("taskTotal"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.svnlog.web.service;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
class SettingsServiceTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnDefaultProviderAndOpenAiDefaults() throws Exception {
|
||||||
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
|
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
|
||||||
|
|
||||||
|
final Map<String, Object> settings = settingsService.getSettings();
|
||||||
|
|
||||||
|
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, settings.get("provider"));
|
||||||
|
Assertions.assertEquals("http://127.0.0.1:5001/v1", settings.get("openaiBaseUrl"));
|
||||||
|
Assertions.assertEquals("sk-f8b3f43e1fdd4f50b287050f08a6c7ed", settings.get("openaiApiKey"));
|
||||||
|
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
|
||||||
|
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldUpdateProviderAndOpenAiSettings() throws Exception {
|
||||||
|
final OutputFileService outputFileService = new OutputFileService();
|
||||||
|
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||||
|
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
|
||||||
|
final String newOutputDir = tempDir.resolve("custom-output").toString();
|
||||||
|
|
||||||
|
settingsService.updateSettings(
|
||||||
|
"sk-deepseek-runtime",
|
||||||
|
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
|
||||||
|
"http://localhost:5001/v1/",
|
||||||
|
"sk-openai-runtime",
|
||||||
|
"deepseek-v4-flash",
|
||||||
|
"deepseek-v4-pro",
|
||||||
|
newOutputDir,
|
||||||
|
"preset-2"
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, Object> settings = settingsService.getSettings();
|
||||||
|
|
||||||
|
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider"));
|
||||||
|
Assertions.assertEquals("http://localhost:5001/v1/", settings.get("openaiBaseUrl"));
|
||||||
|
Assertions.assertEquals("sk-openai-runtime", settings.get("openaiApiKey"));
|
||||||
|
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
|
||||||
|
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
|
||||||
|
Assertions.assertEquals("preset-2", settings.get("defaultSvnPresetId"));
|
||||||
|
Assertions.assertEquals(Paths.get(newOutputDir).toAbsolutePath().normalize().toString(), settings.get("outputDir"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user