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` 文件
|
||||
2. 在「系统设置」配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`)
|
||||
2. 在「系统设置」选择 AI 提供商:
|
||||
- `DeepSeek`:配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`)
|
||||
- `OpenAI兼容`:配置 `Base URL`、`API Key`、第一阶段模型、第二阶段模型
|
||||
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
|
||||
4. 在「任务历史」或「产物列表」下载 `.xlsx`
|
||||
|
||||
## API Key 读取优先级
|
||||
## DeepSeek API Key 读取优先级
|
||||
|
||||
1. 请求中的 `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 参数录入与连接测试
|
||||
2. 异步抓取日志并导出 Markdown
|
||||
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
|
||||
3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel
|
||||
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
|
||||
5. 下载输出文件、配置 API Key 与输出目录
|
||||
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
|
||||
@@ -40,7 +40,7 @@ http://localhost:18088
|
||||
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
|
||||
- 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`
|
||||
- 任务持久化:`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`
|
||||
2. 设置页保存的运行时 `apiKey`
|
||||
|
||||
@@ -126,6 +126,12 @@
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
|
||||
@@ -266,7 +266,16 @@ public class AppController {
|
||||
|
||||
@PutMapping("/settings")
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ package com.svnlog.web.dto;
|
||||
public class SettingsUpdateRequest {
|
||||
|
||||
private String apiKey;
|
||||
private String provider;
|
||||
private String openaiBaseUrl;
|
||||
private String openaiApiKey;
|
||||
private String openaiStageOneModel;
|
||||
private String openaiStageTwoModel;
|
||||
private String outputDir;
|
||||
private String defaultSvnPresetId;
|
||||
|
||||
@@ -14,6 +19,46 @@ public class SettingsUpdateRequest {
|
||||
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() {
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
@@ -119,17 +119,13 @@ public class AiWorkflowService {
|
||||
final Map<String, List<CommitEntry>> projectCommits = buildProjectCommitMap(content);
|
||||
final Map<String, Integer> projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
|
||||
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()
|
||||
? request.getPeriod().trim()
|
||||
: 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();
|
||||
for (int i = 0; i < FIXED_PROJECTS.length; i++) {
|
||||
final String project = FIXED_PROJECTS[i];
|
||||
@@ -143,11 +139,16 @@ public class AiWorkflowService {
|
||||
List<ProjectSummaryItem> stageOneItems = new ArrayList<ProjectSummaryItem>();
|
||||
try {
|
||||
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);
|
||||
} catch (Exception ex) {
|
||||
LOGGER.warn("DeepSeek chat stage failed, fallback to local compression: project={}", project, ex);
|
||||
context.emitEvent("phase", buildEventPayload("Chat 压缩失败,已切换本地压缩: " + project));
|
||||
LOGGER.warn("{} stage one failed, fallback to local compression: project={}",
|
||||
providerContext.displayName,
|
||||
project,
|
||||
ex);
|
||||
context.emitEvent("phase", buildEventPayload(
|
||||
providerContext.displayName + " 压缩失败,已切换本地压缩: " + project
|
||||
));
|
||||
}
|
||||
|
||||
if (stageOneItems.isEmpty()) {
|
||||
@@ -164,12 +165,17 @@ public class AiWorkflowService {
|
||||
context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
|
||||
try {
|
||||
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);
|
||||
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
|
||||
} catch (Exception ex) {
|
||||
LOGGER.warn("DeepSeek think stage failed, fallback to chat merge: project={}", project, ex);
|
||||
context.emitEvent("phase", buildEventPayload("Think 精炼失败,使用 Chat 压缩结果: " + project));
|
||||
LOGGER.warn("{} stage two failed, fallback to stage one merge: project={}",
|
||||
providerContext.displayName,
|
||||
project,
|
||||
ex);
|
||||
context.emitEvent("phase", buildEventPayload(
|
||||
providerContext.displayName + " 精炼失败,使用阶段一压缩结果: " + project
|
||||
));
|
||||
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
|
||||
}
|
||||
}
|
||||
@@ -824,13 +830,72 @@ public class AiWorkflowService {
|
||||
return count;
|
||||
}
|
||||
|
||||
private String callDeepSeek(String apiKey, String prompt, TaskContext context, String model) throws IOException {
|
||||
final String modelName = model == null || model.trim().isEmpty() ? DEEPSEEK_MODEL_CHAT : model.trim();
|
||||
final int primaryMaxTokens = resolvePrimaryMaxTokens(modelName);
|
||||
final int retryMaxTokens = resolveRetryMaxTokens(modelName);
|
||||
String normalizeChatCompletionsUrl(String baseUrl) {
|
||||
final String normalized = baseUrl == null ? "" : baseUrl.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
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 {
|
||||
final DeepSeekStreamResult primary = retrySupport.execute(
|
||||
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, primaryMaxTokens),
|
||||
final AiStreamResult primary = retrySupport.execute(
|
||||
() -> callAiOnce(providerContext, prompt, context, modelName, primaryMaxTokens),
|
||||
3,
|
||||
1000L
|
||||
);
|
||||
@@ -845,16 +910,17 @@ public class AiWorkflowService {
|
||||
}
|
||||
|
||||
context.emitEvent("phase", buildEventPayload(
|
||||
"DeepSeek(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ")"
|
||||
providerContext.displayName
|
||||
+ "(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ")"
|
||||
));
|
||||
final DeepSeekStreamResult retried = retrySupport.execute(
|
||||
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, retryMaxTokens),
|
||||
final AiStreamResult retried = retrySupport.execute(
|
||||
() -> callAiOnce(providerContext, prompt, context, modelName, retryMaxTokens),
|
||||
2,
|
||||
1200L
|
||||
);
|
||||
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
|
||||
throw new IllegalStateException(
|
||||
"DeepSeek 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
|
||||
providerContext.displayName + " 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
|
||||
);
|
||||
}
|
||||
return retried.answer;
|
||||
@@ -867,25 +933,11 @@ public class AiWorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
private int resolvePrimaryMaxTokens(String modelName) {
|
||||
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,
|
||||
TaskContext context,
|
||||
String model,
|
||||
int maxTokens) throws Exception {
|
||||
private AiStreamResult callAiOnce(AiProviderContext providerContext,
|
||||
String prompt,
|
||||
TaskContext context,
|
||||
String model,
|
||||
int maxTokens) throws Exception {
|
||||
final JsonObject message = new JsonObject();
|
||||
message.addProperty("role", "user");
|
||||
message.addProperty("content", prompt);
|
||||
@@ -906,8 +958,8 @@ public class AiWorkflowService {
|
||||
body.add("stream_options", streamOptions);
|
||||
|
||||
final Request request = new Request.Builder()
|
||||
.url(DEEPSEEK_API_URL)
|
||||
.addHeader("Authorization", "Bearer " + apiKey)
|
||||
.url(providerContext.apiUrl)
|
||||
.addHeader("Authorization", "Bearer " + providerContext.apiKey)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
|
||||
.build();
|
||||
@@ -918,129 +970,136 @@ public class AiWorkflowService {
|
||||
if (response.body() != null) {
|
||||
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) {
|
||||
throw new RetrySupport.RetryableException(detail);
|
||||
}
|
||||
throw new IllegalStateException(detail);
|
||||
}
|
||||
if (response.body() == null) {
|
||||
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
|
||||
throw new RetrySupport.RetryableException(providerContext.displayName + " API 返回空响应体");
|
||||
}
|
||||
|
||||
final StringBuilder answerBuilder = new StringBuilder();
|
||||
final StringBuilder reasoningBuilder = new StringBuilder();
|
||||
final okhttp3.ResponseBody responseBody = response.body();
|
||||
final okio.BufferedSource source = responseBody.source();
|
||||
String finishReason = "";
|
||||
int reasoningDeltaCount = 0;
|
||||
int answerDeltaCount = 0;
|
||||
Long usagePromptTokens = null;
|
||||
Long usageCompletionTokens = null;
|
||||
Long usageTotalTokens = null;
|
||||
String finalMessageContent = "";
|
||||
|
||||
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
|
||||
while (!source.exhausted()) {
|
||||
final String line = source.readUtf8Line();
|
||||
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String dataLine = line.substring(5).trim();
|
||||
if ("[DONE]".equals(dataLine)) {
|
||||
break;
|
||||
}
|
||||
|
||||
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
|
||||
if (data.has("usage") && data.get("usage").isJsonObject()) {
|
||||
final JsonObject usage = data.getAsJsonObject("usage");
|
||||
usagePromptTokens = optLong(usage, "prompt_tokens");
|
||||
usageCompletionTokens = optLong(usage, "completion_tokens");
|
||||
usageTotalTokens = optLong(usage, "total_tokens");
|
||||
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
|
||||
usagePayload.put("promptTokens", usagePromptTokens);
|
||||
usagePayload.put("completionTokens", usageCompletionTokens);
|
||||
usagePayload.put("totalTokens", usageTotalTokens);
|
||||
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
|
||||
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
|
||||
context.emitEvent("usage", usagePayload);
|
||||
}
|
||||
|
||||
final JsonArray choices = data.getAsJsonArray("choices");
|
||||
if (choices == null || choices.size() == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||
if (first.has("message") && first.get("message").isJsonObject()) {
|
||||
final String content = optString(first.getAsJsonObject("message"), "content");
|
||||
if (content != null && !content.trim().isEmpty()) {
|
||||
finalMessageContent = content.trim();
|
||||
}
|
||||
}
|
||||
if (first.has("delta") && first.get("delta").isJsonObject()) {
|
||||
final JsonObject delta = first.getAsJsonObject("delta");
|
||||
|
||||
final String reasoning = optString(delta, "reasoning_content");
|
||||
if (reasoning != null && !reasoning.isEmpty()) {
|
||||
reasoningDeltaCount++;
|
||||
reasoningBuilder.append(reasoning);
|
||||
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
|
||||
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
|
||||
}
|
||||
}
|
||||
|
||||
final String answer = optString(delta, "content");
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
answerDeltaCount++;
|
||||
answerBuilder.append(answer);
|
||||
context.emitEvent("answer_delta", buildTextPayload(answer));
|
||||
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
|
||||
finishReason = first.get("finish_reason").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
||||
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
|
||||
}
|
||||
|
||||
String answer = answerBuilder.toString().trim();
|
||||
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
|
||||
answer = finalMessageContent;
|
||||
}
|
||||
if (answer.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"DeepSeek 未返回有效 content 内容"
|
||||
+ " | stage_model=" + model
|
||||
+ " | api_model=" + model
|
||||
+ " | finish_reason=" + finishReason
|
||||
+ " | prompt_tokens=" + usagePromptTokens
|
||||
+ " | completion_tokens=" + usageCompletionTokens
|
||||
+ " | total_tokens=" + usageTotalTokens
|
||||
);
|
||||
}
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answer);
|
||||
context.setAiStreamStatus("DONE");
|
||||
LOGGER.info(
|
||||
"DeepSeek stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
|
||||
model,
|
||||
reasoningDeltaCount,
|
||||
answerDeltaCount,
|
||||
finishReason,
|
||||
maxTokens
|
||||
);
|
||||
return new DeepSeekStreamResult(answer, finishReason);
|
||||
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 reasoningBuilder = new StringBuilder();
|
||||
String finishReason = "";
|
||||
int reasoningDeltaCount = 0;
|
||||
int answerDeltaCount = 0;
|
||||
Long usagePromptTokens = null;
|
||||
Long usageCompletionTokens = null;
|
||||
Long usageTotalTokens = null;
|
||||
String finalMessageContent = "";
|
||||
|
||||
context.emitEvent("phase", buildEventPayload("正在流式接收 " + providerContext.displayName + " 输出"));
|
||||
while (!source.exhausted()) {
|
||||
final String line = source.readUtf8Line();
|
||||
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String dataLine = line.substring(5).trim();
|
||||
if ("[DONE]".equals(dataLine)) {
|
||||
break;
|
||||
}
|
||||
|
||||
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
|
||||
if (data.has("usage") && data.get("usage").isJsonObject()) {
|
||||
final JsonObject usage = data.getAsJsonObject("usage");
|
||||
usagePromptTokens = optLong(usage, "prompt_tokens");
|
||||
usageCompletionTokens = optLong(usage, "completion_tokens");
|
||||
usageTotalTokens = optLong(usage, "total_tokens");
|
||||
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
|
||||
usagePayload.put("promptTokens", usagePromptTokens);
|
||||
usagePayload.put("completionTokens", usageCompletionTokens);
|
||||
usagePayload.put("totalTokens", usageTotalTokens);
|
||||
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
|
||||
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
|
||||
context.emitEvent("usage", usagePayload);
|
||||
}
|
||||
|
||||
final JsonArray choices = data.getAsJsonArray("choices");
|
||||
if (choices == null || choices.size() == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final JsonObject first = choices.get(0).getAsJsonObject();
|
||||
if (first.has("message") && first.get("message").isJsonObject()) {
|
||||
final String content = optString(first.getAsJsonObject("message"), "content");
|
||||
if (content != null && !content.trim().isEmpty()) {
|
||||
finalMessageContent = content.trim();
|
||||
}
|
||||
}
|
||||
if (first.has("delta") && first.get("delta").isJsonObject()) {
|
||||
final JsonObject delta = first.getAsJsonObject("delta");
|
||||
|
||||
final String reasoning = optString(delta, "reasoning_content");
|
||||
if (reasoning != null && !reasoning.isEmpty()) {
|
||||
reasoningDeltaCount++;
|
||||
reasoningBuilder.append(reasoning);
|
||||
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
|
||||
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
|
||||
}
|
||||
}
|
||||
|
||||
final String answer = optString(delta, "content");
|
||||
if (answer != null && !answer.isEmpty()) {
|
||||
answerDeltaCount++;
|
||||
answerBuilder.append(answer);
|
||||
context.emitEvent("answer_delta", buildTextPayload(answer));
|
||||
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
|
||||
finishReason = first.get("finish_reason").getAsString();
|
||||
}
|
||||
}
|
||||
|
||||
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
|
||||
throw new RetrySupport.RetryableException(providerContext.displayName + " 资源不足,请稍后重试");
|
||||
}
|
||||
|
||||
String answer = answerBuilder.toString().trim();
|
||||
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
|
||||
answer = finalMessageContent;
|
||||
}
|
||||
if (answer.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
providerContext.displayName + " 未返回有效 content 内容"
|
||||
+ " | stage_model=" + model
|
||||
+ " | api_model=" + model
|
||||
+ " | finish_reason=" + finishReason
|
||||
+ " | prompt_tokens=" + usagePromptTokens
|
||||
+ " | completion_tokens=" + usageCompletionTokens
|
||||
+ " | total_tokens=" + usageTotalTokens
|
||||
);
|
||||
}
|
||||
context.updateAiOutput(reasoningBuilder.toString(), answer);
|
||||
context.setAiStreamStatus("DONE");
|
||||
LOGGER.info(
|
||||
"{} stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
|
||||
providerContext.displayName,
|
||||
model,
|
||||
reasoningDeltaCount,
|
||||
answerDeltaCount,
|
||||
finishReason,
|
||||
maxTokens
|
||||
);
|
||||
return new AiStreamResult(answer, finishReason);
|
||||
}
|
||||
|
||||
private boolean isValidJsonObjectText(String text) {
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
return false;
|
||||
@@ -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 finishReason;
|
||||
|
||||
private DeepSeekStreamResult(String answer, String finishReason) {
|
||||
private AiStreamResult(String answer, String finishReason) {
|
||||
this.answer = answer == null ? "" : answer;
|
||||
this.finishReason = finishReason == null ? "" : finishReason;
|
||||
}
|
||||
|
||||
String getAnswer() {
|
||||
return answer;
|
||||
}
|
||||
|
||||
String getFinishReason() {
|
||||
return finishReason;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildTextPayload(String text) {
|
||||
|
||||
@@ -8,18 +8,35 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SettingsService {
|
||||
public static final String PROVIDER_DEEPSEEK = "deepseek";
|
||||
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
|
||||
|
||||
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
|
||||
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 SvnPresetService svnPresetService;
|
||||
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;
|
||||
|
||||
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
|
||||
this.outputFileService = outputFileService;
|
||||
this.svnPresetService = svnPresetService;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -28,17 +45,43 @@ public class SettingsService {
|
||||
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
||||
final String activeKey = pickActiveKey(null);
|
||||
|
||||
result.put("provider", getProvider());
|
||||
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
|
||||
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("defaultSvnPresetId", getDefaultSvnPresetId());
|
||||
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()) {
|
||||
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()) {
|
||||
outputFileService.setOutputRoot(outputDir);
|
||||
}
|
||||
@@ -92,4 +135,44 @@ public class SettingsService {
|
||||
}
|
||||
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");
|
||||
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");
|
||||
if (taskFilterBtn) {
|
||||
@@ -870,6 +874,14 @@ function renderFileTable() {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
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 || "";
|
||||
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
||||
const settingsPreset = document.querySelector("#settings-default-preset");
|
||||
@@ -877,7 +889,8 @@ async function loadSettings() {
|
||||
settingsPreset.value = state.defaultPresetId;
|
||||
}
|
||||
applyPresetToSvnForm(state.defaultPresetId);
|
||||
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
|
||||
updateSettingsProviderUI(data.provider || "deepseek");
|
||||
renderSettingsState(data);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
@@ -895,7 +908,8 @@ async function onSaveSettings(event) {
|
||||
});
|
||||
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
|
||||
applyPresetToSvnForm(state.defaultPresetId);
|
||||
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource})`;
|
||||
updateSettingsProviderUI(data.provider || "deepseek");
|
||||
renderSettingsState(data);
|
||||
toast("设置保存成功");
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
@@ -909,6 +923,29 @@ function readForm(form) {
|
||||
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) {
|
||||
if (!button) {
|
||||
return;
|
||||
|
||||
@@ -345,11 +345,45 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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">
|
||||
<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="设置后将保存在当前进程内存">
|
||||
</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">
|
||||
<label for="default-preset" class="form-label">默认 SVN 项目</label>
|
||||
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
|
||||
|
||||
@@ -184,9 +184,35 @@
|
||||
<article class="card form-card">
|
||||
<h3>系统设置</h3>
|
||||
<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
|
||||
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
|
||||
</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 项目
|
||||
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
|
||||
</label>
|
||||
@@ -205,6 +231,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/app.js?v=20260407_1811" defer></script>
|
||||
<script src="/app.js?v=20260429_1808" defer></script>
|
||||
</body>
|
||||
</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;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
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
|
||||
public void shouldReturnDetailedHealthWhenDependenciesAvailable() throws Exception {
|
||||
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
|
||||
SettingsService settingsService = Mockito.mock(SettingsService.class);
|
||||
TaskService taskService = Mockito.mock(TaskService.class);
|
||||
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
|
||||
final OutputFileService outputFileService = new OutputFileService();
|
||||
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
|
||||
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");
|
||||
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();
|
||||
final Map<String, Object> details = healthService.detailedHealth();
|
||||
|
||||
Assertions.assertEquals("ok", details.get("status"));
|
||||
Assertions.assertEquals(true, details.get("outputDirWritable"));
|
||||
Assertions.assertEquals(true, details.get("apiKeyConfigured"));
|
||||
Assertions.assertEquals(0, details.get("taskTotal"));
|
||||
Assertions.assertTrue(Boolean.TRUE.equals(details.get("outputDirWritable")));
|
||||
Assertions.assertTrue(details.containsKey("apiKeyConfigured"));
|
||||
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