feat: 完善多模型配置与运行切换
将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package tech.easyflow.manuagent.agent;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -95,8 +97,27 @@ public class AgentController {
|
||||
* @return 新恢复 Run
|
||||
*/
|
||||
@PostMapping("/runs/resume")
|
||||
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) {
|
||||
return runService.resume(projectId, principal);
|
||||
public AgentRunService.RunView resume(
|
||||
@PathVariable UUID projectId,
|
||||
@Valid @RequestBody(required = false) ResumeInput input,
|
||||
Principal principal) {
|
||||
return runService.resume(projectId, input == null ? null : input.modelConfigId(), principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行中的任务受控切换到替代模型。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param input 替代模型
|
||||
* @param principal 当前用户
|
||||
* @return 绑定替代模型的新恢复 Run
|
||||
*/
|
||||
@PostMapping("/runs/switch-model")
|
||||
public AgentRunService.RunView switchModel(
|
||||
@PathVariable UUID projectId,
|
||||
@Valid @RequestBody ResumeInput input,
|
||||
Principal principal) {
|
||||
return runService.switchModel(projectId, input.modelConfigId(), principal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,4 +159,12 @@ public class AgentController {
|
||||
@RequestParam(defaultValue = "0") long after) {
|
||||
return eventService.streamAfter(projectId, after);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复或切换任务时指定的模型。
|
||||
*
|
||||
* @param modelConfigId 目标模型配置 ID
|
||||
*/
|
||||
public record ResumeInput(@NotNull UUID modelConfigId) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ public class AgentExecutionService {
|
||||
.runId(run.id().toString())
|
||||
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
|
||||
.build();
|
||||
try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) {
|
||||
try (AgentFactory.AgentHandle handle = agentFactory.create(
|
||||
project.id(), run.modelConfigId(), skillService.enabledNames())) {
|
||||
handle.adapter().run(input)
|
||||
.takeUntilOther(stopSignal)
|
||||
.bufferTimeout(64, Duration.ofMillis(120))
|
||||
|
||||
@@ -78,11 +78,13 @@ public class AgentFactory {
|
||||
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId Run 创建时绑定的模型配置 ID
|
||||
* @param enabledSkills 当前启用 Skill
|
||||
* @return 需要在流结束后关闭的 Agent 句柄
|
||||
*/
|
||||
public AgentHandle create(UUID projectId, String[] enabledSkills) {
|
||||
ModelService.ModelSecret model = modelService.defaultModelSecret();
|
||||
public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) {
|
||||
// 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置;全局默认模型只参与新 Run 的选择。
|
||||
ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId);
|
||||
OpenAIChatModel chatModel = OpenAIChatModel.builder()
|
||||
.apiKey(model.apiKey())
|
||||
.baseUrl(model.baseUrl())
|
||||
|
||||
@@ -228,6 +228,19 @@ public class AgentRunService {
|
||||
*/
|
||||
@Transactional
|
||||
public RunView resume(UUID projectId, Principal principal) {
|
||||
return resume(projectId, null, principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已中断 Run 的原阶段继续,并可显式选择本次恢复使用的模型。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 替代模型;为空时使用当前默认模型
|
||||
* @param principal 当前用户
|
||||
* @return 新的恢复 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView resume(UUID projectId, UUID modelConfigId, Principal principal) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
RunView interrupted = latest(projectId);
|
||||
@@ -235,18 +248,71 @@ public class AgentRunService {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
|
||||
}
|
||||
String phase = runStore.interruptedPhase(interrupted, project);
|
||||
RunView run = runStore.create(projectId, "RESUME", interrupted.id());
|
||||
RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
|
||||
projectService.updateStatus(projectId, phase);
|
||||
scheduleResume(project, run, userId, phase);
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行中的任务切换到替代模型,并以新的恢复 Run 保留完整审计边界。
|
||||
*
|
||||
* <p>旧 Run 在事务内先进入中断状态,新 Run 再绑定目标模型;任一步失败都会整体回滚。
|
||||
* 目标 ID 可以与旧 Run 相同,以便模型配置被编辑后重新建立客户端并读取最新配置。
|
||||
* 提交后先取消旧模型流,再从原业务阶段启动新 Run,复用相同 threadId 和工作区。</p>
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 替代模型 ID
|
||||
* @param principal 当前用户
|
||||
* @return 绑定替代模型的新恢复 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView switchModel(UUID projectId, UUID modelConfigId, Principal principal) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
RunView current = latest(projectId);
|
||||
if (current == null || !"RUNNING".equals(current.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||
}
|
||||
String phase = runStore.interruptedPhase(current, project);
|
||||
requireTerminalUpdate(runMapper.interruptRunning(current.id()));
|
||||
eventService.append(projectId, current.id(), "RUN_FINISHED", Map.of(
|
||||
"outcome", "CANCELLED", "reason", "MODEL_SWITCH"));
|
||||
|
||||
RunView replacement = runStore.create(projectId, "RESUME", current.id(), modelConfigId);
|
||||
eventService.append(projectId, replacement.id(), "MODEL_SWITCHED", Map.of(
|
||||
"fromModelConfigId", current.modelConfigId(),
|
||||
"toModelConfigId", replacement.modelConfigId()));
|
||||
projectService.updateStatus(projectId, phase);
|
||||
|
||||
onCommit(() -> {
|
||||
RunControl control = activeRuns.get(current.id());
|
||||
if (control != null) {
|
||||
control.cancel();
|
||||
}
|
||||
});
|
||||
scheduleResume(project, replacement, userId, phase);
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按中断前业务阶段注册恢复任务,所有调用方必须处于创建恢复 Run 的事务中。
|
||||
*/
|
||||
private void scheduleResume(
|
||||
ProjectService.ProjectView project,
|
||||
RunView run,
|
||||
UUID userId,
|
||||
String phase) {
|
||||
switch (phase) {
|
||||
case "MATERIAL_CHECK" -> afterCommit(
|
||||
run.id(), () -> executeMaterialRun(project, run, true));
|
||||
case "PLANNING" -> {
|
||||
JsonNode materialResponse = runStore.latestMaterialResponse(projectId);
|
||||
JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
|
||||
afterCommit(run.id(), () -> executePlanningRun(
|
||||
project, run, userId, materialResponse, true));
|
||||
}
|
||||
case "WRITING" -> {
|
||||
ProjectService.PlanView plan = projectService.currentPlan(projectId);
|
||||
ProjectService.PlanView plan = projectService.currentPlan(project.id());
|
||||
if (plan == null || !"CONFIRMED".equals(plan.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
|
||||
}
|
||||
@@ -255,7 +321,6 @@ public class AgentRunService {
|
||||
default -> throw new ApiException(
|
||||
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -608,6 +673,7 @@ public class AgentRunService {
|
||||
*
|
||||
* @param id Run ID
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
|
||||
* @param triggerType 触发类型
|
||||
* @param status 运行状态
|
||||
* @param pendingInterrupt 待处理 Ask JSON
|
||||
@@ -618,6 +684,7 @@ public class AgentRunService {
|
||||
public record RunView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
UUID modelConfigId,
|
||||
String triggerType,
|
||||
String status,
|
||||
String pendingInterrupt,
|
||||
|
||||
@@ -55,6 +55,27 @@ public class AgentRunStore {
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
||||
return create(projectId, triggerType, parentRunId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定指定模型的新 Run;未指定模型时使用当前启用的默认模型。
|
||||
*
|
||||
* <p>模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换
|
||||
* 只影响新 Run,不会悄悄改变已经开始的任务。</p>
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param triggerType 触发类型
|
||||
* @param parentRunId 父 Run ID
|
||||
* @param requestedModelId 用户显式选择的模型;为空时使用默认模型
|
||||
* @return 新 Run
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // 查询只投影模型 ID,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
public AgentRunService.RunView create(
|
||||
UUID projectId,
|
||||
String triggerType,
|
||||
UUID parentRunId,
|
||||
UUID requestedModelId) {
|
||||
QueryWrapper activeRuns = QueryWrapper.create()
|
||||
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
|
||||
@@ -62,14 +83,20 @@ public class AgentRunStore {
|
||||
if (active > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
||||
}
|
||||
QueryWrapper defaultModel = QueryWrapper.create()
|
||||
.select(ModelConfigEntity::getId)
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel);
|
||||
QueryWrapper modelQuery = QueryWrapper.create()
|
||||
.select(ModelConfigEntity::getId);
|
||||
if (requestedModelId == null) {
|
||||
modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||
} else {
|
||||
modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
|
||||
}
|
||||
modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
|
||||
if (model == null) {
|
||||
// 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。
|
||||
throw new IllegalStateException("数据库中不存在已启用的默认模型");
|
||||
if (requestedModelId == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
|
||||
}
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
|
||||
}
|
||||
|
||||
// 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。
|
||||
@@ -222,6 +249,7 @@ public class AgentRunStore {
|
||||
return new AgentRunService.RunView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getModelConfigId(),
|
||||
entity.getTriggerType(),
|
||||
entity.getStatus(),
|
||||
entity.getPendingInterrupt(),
|
||||
@@ -257,6 +285,7 @@ public class AgentRunStore {
|
||||
return QueryWrapper.create().select(
|
||||
AgentRunEntity::getId,
|
||||
AgentRunEntity::getProjectId,
|
||||
AgentRunEntity::getModelConfigId,
|
||||
AgentRunEntity::getTriggerType,
|
||||
AgentRunEntity::getStatus,
|
||||
AgentRunEntity::getPendingInterrupt,
|
||||
|
||||
@@ -8,14 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* 应用自身的运行配置。
|
||||
*
|
||||
* @param dataRoot 项目材料、工作区和产物根目录
|
||||
* @param deepseekKeyFile DeepSeek Key 文件
|
||||
* @param dashscopeKeyFile 百炼 Key 文件
|
||||
* @param masterKey 模型密钥加密主密钥
|
||||
* @param adminUsername 本地管理员用户名
|
||||
* @param adminPassword 本地管理员初始密码
|
||||
* @param modelBaseUrl 默认模型端点
|
||||
* @param modelId 默认模型标识
|
||||
* @param modelContextWindow 默认模型上下文窗口
|
||||
* @param sandboxImage Agent Docker 运行镜像
|
||||
* @param sandboxNetwork Agent Docker 网络
|
||||
* @param runTimeout 单次 Agent 运行超时
|
||||
@@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public record AppProperties(
|
||||
Path dataRoot,
|
||||
Path deepseekKeyFile,
|
||||
Path dashscopeKeyFile,
|
||||
String masterKey,
|
||||
String adminUsername,
|
||||
String adminPassword,
|
||||
String modelBaseUrl,
|
||||
String modelId,
|
||||
int modelContextWindow,
|
||||
String sandboxImage,
|
||||
String sandboxNetwork,
|
||||
Duration runTimeout) {
|
||||
|
||||
@@ -11,4 +11,7 @@ public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity>
|
||||
|
||||
/** 按角色插入或更新模型分配。 */
|
||||
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
|
||||
|
||||
/** 删除指定模型遗留的角色分配。 */
|
||||
int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId);
|
||||
}
|
||||
|
||||
@@ -42,4 +42,10 @@ public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int setDefault(@Param("id") java.util.UUID id);
|
||||
|
||||
/** 按主键更新模型启用状态,并刷新数据库更新时间。 */
|
||||
int setEnabled(@Param("id") java.util.UUID id, @Param("enabled") boolean enabled);
|
||||
|
||||
/** 删除不再被 Run 或角色分配引用的模型。 */
|
||||
int deleteModel(@Param("id") java.util.UUID id);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package tech.easyflow.manuagent.model;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -72,7 +75,25 @@ public class ModelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试模型连接。
|
||||
* 使用管理页面当前草稿测试模型连接,但不保存草稿内容。
|
||||
*
|
||||
* <p>已有模型且 API 地址未变化时允许不提交 API Key,此时服务层只读取该模型已加密保存的密钥;
|
||||
* 新模型或修改 API 地址后的草稿必须提交 API Key,避免把隐藏密钥转发到其他主机。</p>
|
||||
*
|
||||
* @param input 当前表单中的连接测试输入
|
||||
* @return 测试结果
|
||||
*/
|
||||
@PostMapping("/test")
|
||||
public ModelService.ConnectionResult testDraft(
|
||||
@Valid @RequestBody ModelService.ConnectionTestInput input) {
|
||||
return modelService.test(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用数据库中已保存的完整配置测试模型连接。
|
||||
*
|
||||
* <p>保留该接口以兼容已有调用方;管理页面使用 {@code POST /api/models/test}
|
||||
* 测试未保存草稿。</p>
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @return 测试结果
|
||||
@@ -93,4 +114,37 @@ public class ModelController {
|
||||
public void setDefault(@PathVariable UUID id, Principal principal) {
|
||||
modelService.setDefault(id, principal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或停用模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param input 状态输入
|
||||
* @return 更新后的模型
|
||||
*/
|
||||
@PatchMapping("/{id}/enabled")
|
||||
public ModelService.ModelView setEnabled(
|
||||
@PathVariable UUID id,
|
||||
@Valid @RequestBody EnabledInput input) {
|
||||
return modelService.setEnabled(id, input.enabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除从未被历史 Run 引用的非默认模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void delete(@PathVariable UUID id) {
|
||||
modelService.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型启用状态输入。
|
||||
*
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
public record EnabledInput(@NotNull Boolean enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,10 @@ package tech.easyflow.manuagent.model;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.auth.UserService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.config.AppProperties;
|
||||
import tech.easyflow.manuagent.entity.AppUserEntity;
|
||||
import tech.easyflow.manuagent.entity.ModelAssignmentEntity;
|
||||
import tech.easyflow.manuagent.entity.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.mapper.AppUserMapper;
|
||||
import tech.easyflow.manuagent.entity.AgentRunEntity;
|
||||
import tech.easyflow.manuagent.mapper.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -16,18 +15,16 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.Principal;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -36,15 +33,13 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
|
||||
*/
|
||||
@Service
|
||||
@Order(2)
|
||||
public class ModelService implements ApplicationRunner {
|
||||
public class ModelService {
|
||||
|
||||
private final ModelConfigMapper modelMapper;
|
||||
private final ModelAssignmentMapper assignmentMapper;
|
||||
private final AppUserMapper userMapper;
|
||||
private final AgentRunMapper runMapper;
|
||||
private final UserService userService;
|
||||
private final KeyCipher keyCipher;
|
||||
private final AppProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
@@ -53,81 +48,55 @@ public class ModelService implements ApplicationRunner {
|
||||
*
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param assignmentMapper 角色模型分配 Mapper
|
||||
* @param userMapper 用户 Mapper
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param userService 用户服务
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param properties 应用配置
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
@Autowired
|
||||
public ModelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AppUserMapper userMapper,
|
||||
AgentRunMapper runMapper,
|
||||
UserService userService,
|
||||
KeyCipher keyCipher,
|
||||
AppProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.modelMapper = modelMapper;
|
||||
this.assignmentMapper = assignmentMapper;
|
||||
this.userMapper = userMapper;
|
||||
this.userService = userService;
|
||||
this.keyCipher = keyCipher;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
this(
|
||||
modelMapper,
|
||||
assignmentMapper,
|
||||
runMapper,
|
||||
userService,
|
||||
keyCipher,
|
||||
objectMapper,
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从项目根目录 Key 文件初始化默认模型。
|
||||
* 创建可替换 HTTP 客户端的模型服务,仅供同包测试隔离外部网络边界。
|
||||
*
|
||||
* @param args 启动参数
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param assignmentMapper 角色模型分配 Mapper
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param userService 用户服务
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param httpClient 模型连接使用的 HTTP 客户端
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public void run(ApplicationArguments args) {
|
||||
long count = modelMapper.selectCountByQuery(QueryWrapper.create());
|
||||
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
|
||||
if (key.isBlank()) {
|
||||
return;
|
||||
}
|
||||
QueryWrapper userQuery = QueryWrapper.create()
|
||||
.select(AppUserEntity::getId)
|
||||
.orderBy(AppUserEntity::getCreatedAt).asc()
|
||||
.limit(1);
|
||||
AppUserEntity administrator = userMapper.selectOneByQuery(userQuery);
|
||||
if (administrator == null) {
|
||||
throw new IllegalStateException("初始化默认模型前必须先创建管理员账户");
|
||||
}
|
||||
UUID adminId = administrator.getId();
|
||||
UUID modelId = UUID.randomUUID();
|
||||
ModelConfigEntity model = new ModelConfigEntity();
|
||||
model.setId(modelId);
|
||||
model.setName("默认编排模型");
|
||||
model.setProvider("OPENAI_COMPATIBLE");
|
||||
model.setBaseUrl(properties.modelBaseUrl());
|
||||
model.setModelId(properties.modelId());
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(key));
|
||||
model.setApiKeyHint(hint(key));
|
||||
model.setKeyVersion((short) 1);
|
||||
model.setConfigJson("{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}");
|
||||
model.setCapabilitiesJson(json(Map.of(
|
||||
"toolCalling", true,
|
||||
"reasoning", true,
|
||||
"contextWindow", properties.modelContextWindow())));
|
||||
model.setDefaultModel(true);
|
||||
model.setCreatedBy(adminId);
|
||||
modelMapper.insertModel(model);
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
upsertAssignment(role, modelId, adminId);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法读取默认模型 Key", exception);
|
||||
}
|
||||
ModelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AgentRunMapper runMapper,
|
||||
UserService userService,
|
||||
KeyCipher keyCipher,
|
||||
ObjectMapper objectMapper,
|
||||
HttpClient httpClient) {
|
||||
this.modelMapper = modelMapper;
|
||||
this.assignmentMapper = assignmentMapper;
|
||||
this.runMapper = runMapper;
|
||||
this.userService = userService;
|
||||
this.keyCipher = keyCipher;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,14 +129,23 @@ public class ModelService implements ApplicationRunner {
|
||||
if (input.apiKey() == null || input.apiKey().isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||
}
|
||||
QueryWrapper defaultQuery = QueryWrapper.create()
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||
boolean firstDefault = modelMapper.selectCountByQuery(defaultQuery) == 0;
|
||||
id = UUID.randomUUID();
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
model.setProvider("OPENAI_COMPATIBLE");
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||
model.setKeyVersion((short) 1);
|
||||
model.setDefaultModel(firstDefault);
|
||||
model.setCreatedBy(userId);
|
||||
modelMapper.insertModel(model);
|
||||
if (firstDefault) {
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
upsertAssignment(role, id, userId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
if (input.apiKey() != null && !input.apiKey().isBlank()) {
|
||||
@@ -191,15 +169,67 @@ public class ModelService implements ApplicationRunner {
|
||||
*/
|
||||
@Transactional
|
||||
public void setDefault(UUID id, Principal principal) {
|
||||
require(id);
|
||||
ModelView target = require(id);
|
||||
if (!target.enabled()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
|
||||
}
|
||||
UUID userId = userService.requireUserId(principal.getName());
|
||||
modelMapper.clearDefault();
|
||||
modelMapper.setDefault(id);
|
||||
if (modelMapper.setDefault(id) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
upsertAssignment(role, id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型启用状态。
|
||||
*
|
||||
* <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
|
||||
* 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param enabled 新启用状态
|
||||
* @return 更新后的安全模型视图
|
||||
*/
|
||||
@Transactional
|
||||
public ModelView setEnabled(UUID id, boolean enabled) {
|
||||
ModelView current = require(id);
|
||||
if (!enabled && current.defaultModel()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "请先设置新的默认模型");
|
||||
}
|
||||
if (!enabled && countRuns(id, true) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_IN_USE", "模型正在被运行中的任务使用");
|
||||
}
|
||||
if (modelMapper.setEnabled(id, enabled) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
return require(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真删除从未被历史 Run 使用的非默认模型。
|
||||
*
|
||||
* <p>历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。</p>
|
||||
*
|
||||
* @param id 模型 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(UUID id) {
|
||||
ModelView current = require(id);
|
||||
if (current.defaultModel()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "默认模型不能删除");
|
||||
}
|
||||
if (countRuns(id, false) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_HISTORY_EXISTS", "模型已有任务记录,请改为停用");
|
||||
}
|
||||
assignmentMapper.deleteByModelConfigId(id);
|
||||
if (modelMapper.deleteModel(id) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用最小 Chat Completion 请求测试连接。
|
||||
*
|
||||
@@ -207,7 +237,40 @@ public class ModelService implements ApplicationRunner {
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ConnectionResult test(UUID id) {
|
||||
ModelSecret model = requireSecret(id);
|
||||
return testConnection(requireRuntimeModel(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。
|
||||
*
|
||||
* <p>编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key,
|
||||
* 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。</p>
|
||||
*
|
||||
* @param input 当前模型连接草稿
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ConnectionResult test(ConnectionTestInput input) {
|
||||
String baseUrl = normalizeBaseUrl(input.baseUrl());
|
||||
String apiKey = input.apiKey() == null ? "" : input.apiKey().trim();
|
||||
if (apiKey.isBlank()) {
|
||||
apiKey = storedApiKey(input.id(), baseUrl);
|
||||
}
|
||||
ModelSecret draft = new ModelSecret(
|
||||
input.id(),
|
||||
baseUrl,
|
||||
input.modelId().trim(),
|
||||
apiKey,
|
||||
0);
|
||||
return testConnection(draft);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 OpenAI 兼容接口发送最小 Chat Completion 请求。
|
||||
*
|
||||
* @param model 已解析出明文密钥的临时连接配置
|
||||
* @return 测试结果
|
||||
*/
|
||||
private ConnectionResult testConnection(ModelSecret model) {
|
||||
String requestJson = json(Map.of(
|
||||
"model", model.modelId(),
|
||||
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
|
||||
@@ -271,7 +334,16 @@ public class ModelService implements ApplicationRunner {
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
private ModelSecret requireSecret(UUID id) {
|
||||
/**
|
||||
* 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key,仅供模型调用链使用。
|
||||
*
|
||||
* <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run;
|
||||
* 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
|
||||
*
|
||||
* @param id Run 绑定的模型配置 ID
|
||||
* @return 可直接创建模型客户端的机密配置
|
||||
*/
|
||||
public ModelSecret requireRuntimeModel(UUID id) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
@@ -323,6 +395,41 @@ public class ModelService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取已有模型的保存密钥,供“API Key 留空”的草稿测试临时使用。
|
||||
*
|
||||
* <p>查询只投影主键和密文字段,不要求模型处于启用状态,因为管理员需要先验证配置,
|
||||
* 再决定是否重新启用。密钥只在服务端内存中短暂解密,永不写入响应或日志。</p>
|
||||
*
|
||||
* @param id 已保存模型 ID;新增草稿没有 ID
|
||||
* @param targetBaseUrl 已规范化的草稿 API 地址
|
||||
* @return 已解密 API Key
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private String storedApiKey(UUID id, String targetBaseUrl) {
|
||||
if (id == null) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "测试新模型需要 API Key");
|
||||
}
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getApiKeyCiphertext)
|
||||
.where(ModelConfigEntity::getId).eq(id);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
// 隐藏密钥只能发往它原本绑定的地址,草稿换址必须由用户显式提供新密钥。
|
||||
if (!normalizeBaseUrl(model.getBaseUrl()).equals(targetBaseUrl)) {
|
||||
throw new ApiException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"MODEL_KEY_REQUIRED_FOR_NEW_BASE_URL",
|
||||
"API 地址变更后需要重新输入 API Key");
|
||||
}
|
||||
return keyCipher.decrypt(model.getApiKeyCiphertext());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造新增、更新共用的非敏感模型字段。
|
||||
*
|
||||
@@ -352,6 +459,18 @@ public class ModelService implements ApplicationRunner {
|
||||
assignmentMapper.upsert(assignment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计模型的 Run 引用;停用检查只关心正在执行的 Run,删除检查覆盖全部历史记录。
|
||||
*/
|
||||
private long countRuns(UUID modelId, boolean runningOnly) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.where(AgentRunEntity::getModelConfigId).eq(modelId);
|
||||
if (runningOnly) {
|
||||
query.and(AgentRunEntity::getStatus).eq("RUNNING");
|
||||
}
|
||||
return runMapper.selectCountByQuery(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将包含密文的内部实体转换为模型调用所需的最小明文对象。
|
||||
*/
|
||||
@@ -414,14 +533,52 @@ public class ModelService implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化用户提供的 OpenAI 兼容服务根地址。
|
||||
*
|
||||
* <p>模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。
|
||||
* 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。
|
||||
* 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。</p>
|
||||
*
|
||||
* @param baseUrl 用户输入的服务根地址
|
||||
* @return 去除末尾斜杠后的规范地址
|
||||
* @throws ApiException 地址结构或协议不符合要求时抛出
|
||||
*/
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
String value = baseUrl.trim();
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
boolean httpScheme = "http".equals(scheme) || "https".equals(scheme);
|
||||
boolean stableRequestTarget = uri.getHost() != null
|
||||
&& !uri.getHost().isBlank()
|
||||
&& uri.getUserInfo() == null
|
||||
&& uri.getRawQuery() == null
|
||||
&& uri.getRawFragment() == null;
|
||||
if (!httpScheme || uri.isOpaque() || !stableRequestTarget) {
|
||||
throw invalidBaseUrl();
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalidBaseUrl();
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造不回显原始地址的统一校验异常,避免地址中意外携带的凭据进入日志或接口响应。
|
||||
*
|
||||
* @return 模型地址校验异常
|
||||
*/
|
||||
private ApiException invalidBaseUrl() {
|
||||
return new ApiException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"MODEL_BASE_URL_INVALID",
|
||||
"模型 API 地址必须是有效的 HTTP 或 HTTPS 地址,且不能包含用户信息、查询参数或片段");
|
||||
}
|
||||
|
||||
private static String hint(String key) {
|
||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
||||
}
|
||||
@@ -437,14 +594,29 @@ public class ModelService implements ApplicationRunner {
|
||||
* @param capabilities 能力声明
|
||||
*/
|
||||
public record ModelInput(
|
||||
@NotBlank String name,
|
||||
@NotBlank String baseUrl,
|
||||
@NotBlank String modelId,
|
||||
String apiKey,
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
@NotBlank @Size(max = 500) String baseUrl,
|
||||
@NotBlank @Size(max = 255) String modelId,
|
||||
@Size(max = 4096) String apiKey,
|
||||
Map<String, Object> config,
|
||||
Map<String, Object> capabilities) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不落库的模型连接测试输入。
|
||||
*
|
||||
* @param id 已有模型 ID;新增草稿为 {@code null}
|
||||
* @param baseUrl 当前表单中的 API 地址
|
||||
* @param modelId 当前表单中的模型标识
|
||||
* @param apiKey 当前表单中的新密钥;已有模型且 API 地址未变化时留空可复用保存密钥
|
||||
*/
|
||||
public record ConnectionTestInput(
|
||||
UUID id,
|
||||
@NotBlank @Size(max = 500) String baseUrl,
|
||||
@NotBlank @Size(max = 255) String modelId,
|
||||
@Size(max = 4096) String apiKey) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外模型视图。
|
||||
*
|
||||
|
||||
@@ -42,14 +42,10 @@ server:
|
||||
|
||||
app:
|
||||
data-root: file:../data
|
||||
deepseek-key-file: ./deepseek_key.txt
|
||||
dashscope-key-file: ./dashscope_key.txt
|
||||
master-key: smart-factory-local-master-key
|
||||
admin-username: admin
|
||||
admin-password: admin123
|
||||
model-base-url: https://api.deepseek.com
|
||||
model-id: deepseek-v4-flash
|
||||
model-context-window: 131072
|
||||
sandbox-image: smart-factory-agent-runtime:0.1.0
|
||||
sandbox-network: bridge
|
||||
run-timeout: 60m
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
assigned_by = EXCLUDED.assigned_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
</insert>
|
||||
|
||||
<delete id="deleteByModelConfigId">
|
||||
DELETE FROM app.model_assignment
|
||||
WHERE model_config_id = #{modelConfigId,
|
||||
typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
</mapper>
|
||||
|
||||
@@ -60,4 +60,16 @@
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
|
||||
<update id="setEnabled">
|
||||
UPDATE app.model_config
|
||||
SET enabled = #{enabled},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
|
||||
<delete id="deleteModel">
|
||||
DELETE FROM app.model_config
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
</mapper>
|
||||
|
||||
Reference in New Issue
Block a user