feat: 完善多模型配置与运行切换

将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
Zhu Junhao
2026-09-03 10:46:25 +08:00
parent e968a8ddc1
commit 988c0fe555
36 changed files with 1715 additions and 190 deletions

View File

@@ -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) {
}
}

View File

@@ -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))

View File

@@ -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())

View File

@@ -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,

View File

@@ -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") // 查询只投影模型 IDLambdaGetter 可变参数不会引入运行期类型风险。
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,

View File

@@ -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) {

View File

@@ -11,4 +11,7 @@ public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity>
/** 按角色插入或更新模型分配。 */
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
/** 删除指定模型遗留的角色分配。 */
int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId);
}

View File

@@ -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);
}

View File

@@ -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) {
}
}

View File

@@ -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) {
}
/**
* 对外模型视图。
*

View File

@@ -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

View File

@@ -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>

View File

@@ -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>