From 3555d19b26ad41e4ec7154de2a119e23a3e1ee43 Mon Sep 17 00:00:00 2001 From: liumangmang Date: Wed, 29 Apr 2026 22:19:00 +0800 Subject: [PATCH] feat: support deepseek and openai-compatible providers --- docs/README_DeepSeek.md | 11 +- docs/README_Web.md | 15 +- pom.xml | 6 + .../svnlog/web/controller/AppController.java | 11 +- .../svnlog/web/dto/SettingsUpdateRequest.java | 45 ++ .../svnlog/web/service/AiWorkflowService.java | 420 +++++++++++------- .../svnlog/web/service/SettingsService.java | 85 +++- src/main/resources/static/app.js | 41 +- src/main/resources/static/index-redesign.html | 34 ++ src/main/resources/static/index.html | 28 +- .../web/service/AiWorkflowServiceTest.java | 150 +++++++ .../svnlog/web/service/HealthServiceTest.java | 46 +- .../web/service/SettingsServiceTest.java | 59 +++ 13 files changed, 761 insertions(+), 190 deletions(-) create mode 100644 src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java create mode 100644 src/test/java/com/svnlog/web/service/SettingsServiceTest.java diff --git a/docs/README_DeepSeek.md b/docs/README_DeepSeek.md index a997af1..bc7ffac 100644 --- a/docs/README_DeepSeek.md +++ b/docs/README_DeepSeek.md @@ -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` 流式接口 - 接口调用可能产生费用,建议控制调用频率 diff --git a/docs/README_Web.md b/docs/README_Web.md index dccbc76..82a76d1 100644 --- a/docs/README_Web.md +++ b/docs/README_Web.md @@ -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` diff --git a/pom.xml b/pom.xml index ed808dd..517a540 100644 --- a/pom.xml +++ b/pom.xml @@ -126,6 +126,12 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + org.springframework.boot spring-boot-maven-plugin diff --git a/src/main/java/com/svnlog/web/controller/AppController.java b/src/main/java/com/svnlog/web/controller/AppController.java index c876bd0..bfa655f 100644 --- a/src/main/java/com/svnlog/web/controller/AppController.java +++ b/src/main/java/com/svnlog/web/controller/AppController.java @@ -266,7 +266,16 @@ public class AppController { @PutMapping("/settings") public Map 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(); } diff --git a/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java b/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java index e7431ae..2650c1c 100644 --- a/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java +++ b/src/main/java/com/svnlog/web/dto/SettingsUpdateRequest.java @@ -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; } diff --git a/src/main/java/com/svnlog/web/service/AiWorkflowService.java b/src/main/java/com/svnlog/web/service/AiWorkflowService.java index a1bbb46..feb11f0 100644 --- a/src/main/java/com/svnlog/web/service/AiWorkflowService.java +++ b/src/main/java/com/svnlog/web/service/AiWorkflowService.java @@ -119,17 +119,13 @@ public class AiWorkflowService { final Map> projectCommits = buildProjectCommitMap(content); final Map projectLogCounts = buildProjectLogCountsFromCommits(projectCommits); final Map 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> 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 stageOneItems = new ArrayList(); 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 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 usagePayload = new LinkedHashMap(); - 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 usagePayload = new LinkedHashMap(); + 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 buildTextPayload(String text) { diff --git a/src/main/java/com/svnlog/web/service/SettingsService.java b/src/main/java/com/svnlog/web/service/SettingsService.java index 427f263..b33602f 100644 --- a/src/main/java/com/svnlog/web/service/SettingsService.java +++ b/src/main/java/com/svnlog/web/service/SettingsService.java @@ -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(); + } } diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 694d633..71de585 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -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; diff --git a/src/main/resources/static/index-redesign.html b/src/main/resources/static/index-redesign.html index 6c6673e..358dd4b 100644 --- a/src/main/resources/static/index-redesign.html +++ b/src/main/resources/static/index-redesign.html @@ -345,11 +345,45 @@
+
+ + +
+
+ + + + + + + +
diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index dc7551b..42c57b6 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -184,9 +184,35 @@

系统设置

+ + @@ -205,6 +231,6 @@
- + diff --git a/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java new file mode 100644 index 0000000..5f29e14 --- /dev/null +++ b/src/test/java/com/svnlog/web/service/AiWorkflowServiceTest.java @@ -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; + } + } +} diff --git a/src/test/java/com/svnlog/web/service/HealthServiceTest.java b/src/test/java/com/svnlog/web/service/HealthServiceTest.java index f7e7f25..0f961de 100644 --- a/src/test/java/com/svnlog/web/service/HealthServiceTest.java +++ b/src/test/java/com/svnlog/web/service/HealthServiceTest.java @@ -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 settings = new HashMap(); - 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 details = healthService.detailedHealth(); + final Map 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")); } } diff --git a/src/test/java/com/svnlog/web/service/SettingsServiceTest.java b/src/test/java/com/svnlog/web/service/SettingsServiceTest.java new file mode 100644 index 0000000..06d3cf5 --- /dev/null +++ b/src/test/java/com/svnlog/web/service/SettingsServiceTest.java @@ -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 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 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")); + } +}