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

3
.gitignore vendored
View File

@@ -14,4 +14,5 @@ __pycache__/
*.py[cod] *.py[cod]
.env.* .env.*
!.env.example !.env.example
test_data/ deepseek_key.txt
test_data/

View File

@@ -20,7 +20,14 @@ npm --prefix client run dev
打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123` 打开 <http://127.0.0.1:5173>,本地默认账号为 `admin / admin123`
项目根目录的 `deepseek_key.txt``dashscope_key.txt` 分别供模型与百炼知识库使用模型、Skill 也可在页面内查看或配置。 模型连接只能在“模型配置”页面新增并持久化到 PostgreSQLAPI Key 会使用 `APP_MASTER_KEY`
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
启动后端前必须设置模型密钥加密主密钥:
```powershell
$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
```
## 验证 ## 验证

View File

@@ -1 +0,0 @@
sk-8d1419754bce4306bc99854ee5ccd505

View File

@@ -1,6 +1,8 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -95,8 +97,27 @@ public class AgentController {
* @return 新恢复 Run * @return 新恢复 Run
*/ */
@PostMapping("/runs/resume") @PostMapping("/runs/resume")
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) { public AgentRunService.RunView resume(
return runService.resume(projectId, principal); @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) { @RequestParam(defaultValue = "0") long after) {
return eventService.streamAfter(projectId, 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()) .runId(run.id().toString())
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt))) .messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
.build(); .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) handle.adapter().run(input)
.takeUntilOther(stopSignal) .takeUntilOther(stopSignal)
.bufferTimeout(64, Duration.ofMillis(120)) .bufferTimeout(64, Duration.ofMillis(120))

View File

@@ -78,11 +78,13 @@ public class AgentFactory {
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。 * 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
* *
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId Run 创建时绑定的模型配置 ID
* @param enabledSkills 当前启用 Skill * @param enabledSkills 当前启用 Skill
* @return 需要在流结束后关闭的 Agent 句柄 * @return 需要在流结束后关闭的 Agent 句柄
*/ */
public AgentHandle create(UUID projectId, String[] enabledSkills) { public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) {
ModelService.ModelSecret model = modelService.defaultModelSecret(); // 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置全局默认模型只参与新 Run 的选择。
ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId);
OpenAIChatModel chatModel = OpenAIChatModel.builder() OpenAIChatModel chatModel = OpenAIChatModel.builder()
.apiKey(model.apiKey()) .apiKey(model.apiKey())
.baseUrl(model.baseUrl()) .baseUrl(model.baseUrl())

View File

@@ -228,6 +228,19 @@ public class AgentRunService {
*/ */
@Transactional @Transactional
public RunView resume(UUID projectId, Principal principal) { 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); ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName()); UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId); RunView interrupted = latest(projectId);
@@ -235,18 +248,71 @@ public class AgentRunService {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
} }
String phase = runStore.interruptedPhase(interrupted, project); 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); 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) { switch (phase) {
case "MATERIAL_CHECK" -> afterCommit( case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true)); run.id(), () -> executeMaterialRun(project, run, true));
case "PLANNING" -> { case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(projectId); JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
afterCommit(run.id(), () -> executePlanningRun( afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true)); project, run, userId, materialResponse, true));
} }
case "WRITING" -> { case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(projectId); ProjectService.PlanView plan = projectService.currentPlan(project.id());
if (plan == null || !"CONFIRMED".equals(plan.status())) { if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认"); throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
} }
@@ -255,7 +321,6 @@ public class AgentRunService {
default -> throw new ApiException( default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段"); HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
} }
return run;
} }
/** /**
@@ -608,6 +673,7 @@ public class AgentRunService {
* *
* @param id Run ID * @param id Run ID
* @param projectId 项目 ID * @param projectId 项目 ID
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
* @param triggerType 触发类型 * @param triggerType 触发类型
* @param status 运行状态 * @param status 运行状态
* @param pendingInterrupt 待处理 Ask JSON * @param pendingInterrupt 待处理 Ask JSON
@@ -618,6 +684,7 @@ public class AgentRunService {
public record RunView( public record RunView(
UUID id, UUID id,
UUID projectId, UUID projectId,
UUID modelConfigId,
String triggerType, String triggerType,
String status, String status,
String pendingInterrupt, String pendingInterrupt,

View File

@@ -55,6 +55,27 @@ public class AgentRunStore {
*/ */
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。 @SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) { 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() QueryWrapper activeRuns = QueryWrapper.create()
.where(AgentRunEntity::getProjectId).eq(projectId) .where(AgentRunEntity::getProjectId).eq(projectId)
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT"); .and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
@@ -62,14 +83,20 @@ public class AgentRunStore {
if (active > 0) { if (active > 0) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务"); throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
} }
QueryWrapper defaultModel = QueryWrapper.create() QueryWrapper modelQuery = QueryWrapper.create()
.select(ModelConfigEntity::getId) .select(ModelConfigEntity::getId);
.where(ModelConfigEntity::getDefaultModel).eq(true) if (requestedModelId == null) {
.and(ModelConfigEntity::getEnabled).eq(true); modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
ModelConfigEntity model = modelMapper.selectOneByQuery(defaultModel); } else {
modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
}
modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
if (model == null) { if (model == null) {
// 迁移前的强制单条查询在该数据库不变量失效时进入统一 500 路径,不能新增 409 业务语义。 if (requestedModelId == null) {
throw new IllegalStateException("数据库中不存在已启用默认模型"); throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
}
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
} }
// 应用层提前生成 Run 与追踪 ID时间字段仍交由数据库默认值统一生成。 // 应用层提前生成 Run 与追踪 ID时间字段仍交由数据库默认值统一生成。
@@ -222,6 +249,7 @@ public class AgentRunStore {
return new AgentRunService.RunView( return new AgentRunService.RunView(
entity.getId(), entity.getId(),
entity.getProjectId(), entity.getProjectId(),
entity.getModelConfigId(),
entity.getTriggerType(), entity.getTriggerType(),
entity.getStatus(), entity.getStatus(),
entity.getPendingInterrupt(), entity.getPendingInterrupt(),
@@ -257,6 +285,7 @@ public class AgentRunStore {
return QueryWrapper.create().select( return QueryWrapper.create().select(
AgentRunEntity::getId, AgentRunEntity::getId,
AgentRunEntity::getProjectId, AgentRunEntity::getProjectId,
AgentRunEntity::getModelConfigId,
AgentRunEntity::getTriggerType, AgentRunEntity::getTriggerType,
AgentRunEntity::getStatus, AgentRunEntity::getStatus,
AgentRunEntity::getPendingInterrupt, AgentRunEntity::getPendingInterrupt,

View File

@@ -8,14 +8,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* 应用自身的运行配置。 * 应用自身的运行配置。
* *
* @param dataRoot 项目材料、工作区和产物根目录 * @param dataRoot 项目材料、工作区和产物根目录
* @param deepseekKeyFile DeepSeek Key 文件
* @param dashscopeKeyFile 百炼 Key 文件 * @param dashscopeKeyFile 百炼 Key 文件
* @param masterKey 模型密钥加密主密钥 * @param masterKey 模型密钥加密主密钥
* @param adminUsername 本地管理员用户名 * @param adminUsername 本地管理员用户名
* @param adminPassword 本地管理员初始密码 * @param adminPassword 本地管理员初始密码
* @param modelBaseUrl 默认模型端点
* @param modelId 默认模型标识
* @param modelContextWindow 默认模型上下文窗口
* @param sandboxImage Agent Docker 运行镜像 * @param sandboxImage Agent Docker 运行镜像
* @param sandboxNetwork Agent Docker 网络 * @param sandboxNetwork Agent Docker 网络
* @param runTimeout 单次 Agent 运行超时 * @param runTimeout 单次 Agent 运行超时
@@ -23,14 +19,10 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app") @ConfigurationProperties(prefix = "app")
public record AppProperties( public record AppProperties(
Path dataRoot, Path dataRoot,
Path deepseekKeyFile,
Path dashscopeKeyFile, Path dashscopeKeyFile,
String masterKey, String masterKey,
String adminUsername, String adminUsername,
String adminPassword, String adminPassword,
String modelBaseUrl,
String modelId,
int modelContextWindow,
String sandboxImage, String sandboxImage,
String sandboxNetwork, String sandboxNetwork,
Duration runTimeout) { Duration runTimeout) {

View File

@@ -11,4 +11,7 @@ public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity>
/** 按角色插入或更新模型分配。 */ /** 按角色插入或更新模型分配。 */
int upsert(@Param("assignment") ModelAssignmentEntity assignment); 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 受影响行数 * @return 受影响行数
*/ */
int setDefault(@Param("id") java.util.UUID id); 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; package tech.easyflow.manuagent.model;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; 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 * @param id 模型 ID
* @return 测试结果 * @return 测试结果
@@ -93,4 +114,37 @@ public class ModelController {
public void setDefault(@PathVariable UUID id, Principal principal) { public void setDefault(@PathVariable UUID id, Principal principal) {
modelService.setDefault(id, 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 com.mybatisflex.core.query.QueryWrapper;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException; 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.ModelAssignmentEntity;
import tech.easyflow.manuagent.entity.ModelConfigEntity; 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.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -16,18 +15,16 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal; import java.security.Principal;
import java.time.Duration; import java.time.Duration;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments; import jakarta.validation.constraints.Size;
import org.springframework.boot.ApplicationRunner; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -36,15 +33,13 @@ import org.springframework.transaction.annotation.Transactional;
* 管理 OpenAI 兼容模型配置、密钥和连接测试。 * 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/ */
@Service @Service
@Order(2) public class ModelService {
public class ModelService implements ApplicationRunner {
private final ModelConfigMapper modelMapper; private final ModelConfigMapper modelMapper;
private final ModelAssignmentMapper assignmentMapper; private final ModelAssignmentMapper assignmentMapper;
private final AppUserMapper userMapper; private final AgentRunMapper runMapper;
private final UserService userService; private final UserService userService;
private final KeyCipher keyCipher; private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final HttpClient httpClient; private final HttpClient httpClient;
@@ -53,81 +48,55 @@ public class ModelService implements ApplicationRunner {
* *
* @param modelMapper 模型配置 Mapper * @param modelMapper 模型配置 Mapper
* @param assignmentMapper 角色模型分配 Mapper * @param assignmentMapper 角色模型分配 Mapper
* @param userMapper 用户 Mapper * @param runMapper Agent Run Mapper
* @param userService 用户服务 * @param userService 用户服务
* @param keyCipher 密钥加密器 * @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器 * @param objectMapper JSON 映射器
*/ */
@Autowired
public ModelService( public ModelService(
ModelConfigMapper modelMapper, ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper, ModelAssignmentMapper assignmentMapper,
AppUserMapper userMapper, AgentRunMapper runMapper,
UserService userService, UserService userService,
KeyCipher keyCipher, KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.modelMapper = modelMapper; this(
this.assignmentMapper = assignmentMapper; modelMapper,
this.userMapper = userMapper; assignmentMapper,
this.userService = userService; runMapper,
this.keyCipher = keyCipher; userService,
this.properties = properties; keyCipher,
this.objectMapper = objectMapper; objectMapper,
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); 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 ModelService(
@Transactional ModelConfigMapper modelMapper,
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。 ModelAssignmentMapper assignmentMapper,
public void run(ApplicationArguments args) { AgentRunMapper runMapper,
long count = modelMapper.selectCountByQuery(QueryWrapper.create()); UserService userService,
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) { KeyCipher keyCipher,
return; ObjectMapper objectMapper,
} HttpClient httpClient) {
try { this.modelMapper = modelMapper;
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim(); this.assignmentMapper = assignmentMapper;
if (key.isBlank()) { this.runMapper = runMapper;
return; this.userService = userService;
} this.keyCipher = keyCipher;
QueryWrapper userQuery = QueryWrapper.create() this.objectMapper = objectMapper;
.select(AppUserEntity::getId) this.httpClient = httpClient;
.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);
}
} }
/** /**
@@ -160,14 +129,23 @@ public class ModelService implements ApplicationRunner {
if (input.apiKey() == null || input.apiKey().isBlank()) { if (input.apiKey() == null || input.apiKey().isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key"); 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(); id = UUID.randomUUID();
ModelConfigEntity model = editableModel(id, input); ModelConfigEntity model = editableModel(id, input);
model.setProvider("OPENAI_COMPATIBLE"); model.setProvider("OPENAI_COMPATIBLE");
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim())); model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
model.setApiKeyHint(hint(input.apiKey().trim())); model.setApiKeyHint(hint(input.apiKey().trim()));
model.setKeyVersion((short) 1); model.setKeyVersion((short) 1);
model.setDefaultModel(firstDefault);
model.setCreatedBy(userId); model.setCreatedBy(userId);
modelMapper.insertModel(model); modelMapper.insertModel(model);
if (firstDefault) {
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
upsertAssignment(role, id, userId);
}
}
} else { } else {
ModelConfigEntity model = editableModel(id, input); ModelConfigEntity model = editableModel(id, input);
if (input.apiKey() != null && !input.apiKey().isBlank()) { if (input.apiKey() != null && !input.apiKey().isBlank()) {
@@ -191,15 +169,67 @@ public class ModelService implements ApplicationRunner {
*/ */
@Transactional @Transactional
public void setDefault(UUID id, Principal principal) { 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()); UUID userId = userService.requireUserId(principal.getName());
modelMapper.clearDefault(); 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")) { for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
upsertAssignment(role, id, userId); 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 请求测试连接。 * 使用最小 Chat Completion 请求测试连接。
* *
@@ -207,7 +237,40 @@ public class ModelService implements ApplicationRunner {
* @return 测试结果 * @return 测试结果
*/ */
public ConnectionResult test(UUID id) { 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( String requestJson = json(Map.of(
"model", model.modelId(), "model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")), "messages", List.of(Map.of("role", "user", "content", "回复 OK")),
@@ -271,7 +334,16 @@ public class ModelService implements ApplicationRunner {
} }
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列LambdaGetter 可变参数不会引入运行期类型风险。 @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() QueryWrapper query = QueryWrapper.create()
.select( .select(
ModelConfigEntity::getId, 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); 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) { private String normalizeBaseUrl(String baseUrl) {
String value = baseUrl.trim(); 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("/")) { while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1); value = value.substring(0, value.length() - 1);
} }
return value; 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) { private static String hint(String key) {
return "••••" + key.substring(Math.max(0, key.length() - 4)); return "••••" + key.substring(Math.max(0, key.length() - 4));
} }
@@ -437,14 +594,29 @@ public class ModelService implements ApplicationRunner {
* @param capabilities 能力声明 * @param capabilities 能力声明
*/ */
public record ModelInput( public record ModelInput(
@NotBlank String name, @NotBlank @Size(max = 100) String name,
@NotBlank String baseUrl, @NotBlank @Size(max = 500) String baseUrl,
@NotBlank String modelId, @NotBlank @Size(max = 255) String modelId,
String apiKey, @Size(max = 4096) String apiKey,
Map<String, Object> config, Map<String, Object> config,
Map<String, Object> capabilities) { 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: app:
data-root: file:../data data-root: file:../data
deepseek-key-file: ./deepseek_key.txt
dashscope-key-file: ./dashscope_key.txt dashscope-key-file: ./dashscope_key.txt
master-key: smart-factory-local-master-key master-key: smart-factory-local-master-key
admin-username: admin admin-username: admin
admin-password: admin123 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-image: smart-factory-agent-runtime:0.1.0
sandbox-network: bridge sandbox-network: bridge
run-timeout: 60m run-timeout: 60m

View File

@@ -15,4 +15,10 @@
assigned_by = EXCLUDED.assigned_by, assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
</insert> </insert>
<delete id="deleteByModelConfigId">
DELETE FROM app.model_assignment
WHERE model_config_id = #{modelConfigId,
typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</delete>
</mapper> </mapper>

View File

@@ -60,4 +60,16 @@
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler} WHERE id = #{id, typeHandler=tech.easyflow.manuagent.typehandler.UuidTypeHandler}
</update> </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> </mapper>

View File

@@ -1,6 +1,7 @@
package tech.easyflow.manuagent; package tech.easyflow.manuagent;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -373,7 +374,7 @@ class DatabaseAndEventIntegrationTest {
} }
/** /**
* 验证模型新增、保留旧密钥更新、默认分配及密钥解密读取 * 验证多个模型持久化、首模型自动默认、保留旧密钥更新以及默认模型切换
* *
* @throws Exception Mapper XML 初始化失败时抛出 * @throws Exception Mapper XML 初始化失败时抛出
*/ */
@@ -386,24 +387,19 @@ class DatabaseAndEventIntegrationTest {
when(users.requireUserId("admin")).thenReturn(userId); when(users.requireUserId("admin")).thenReturn(userId);
AppProperties properties = new AppProperties( AppProperties properties = new AppProperties(
temporaryDirectory, temporaryDirectory,
temporaryDirectory.resolve("deepseek.key"),
temporaryDirectory.resolve("dashscope.key"), temporaryDirectory.resolve("dashscope.key"),
"integration-master-key", "integration-master-key",
"admin", "admin",
"admin", "admin",
"https://default.example.test",
"default-model",
131_072,
"runtime:test", "runtime:test",
"bridge", "bridge",
Duration.ofMinutes(1)); Duration.ofMinutes(1));
ModelService service = new ModelService( ModelService service = new ModelService(
modelConfigMapper(), modelConfigMapper(),
modelAssignmentMapper(), modelAssignmentMapper(),
mock(AppUserMapper.class), agentRunMapper(),
users, users,
new KeyCipher(properties), new KeyCipher(properties),
properties,
new ObjectMapper()); new ObjectMapper());
Map<String, Object> capabilities = Map.of( Map<String, Object> capabilities = Map.of(
"toolCalling", true, "reasoning", true, "contextWindow", 65_536); "toolCalling", true, "reasoning", true, "contextWindow", 65_536);
@@ -420,16 +416,61 @@ class DatabaseAndEventIntegrationTest {
"测试模型更新", "https://model.example.test", "model-v2", "", "测试模型更新", "https://model.example.test", "model-v2", "",
Map.of("timeoutSeconds", 120), capabilities), Map.of("timeoutSeconds", 120), capabilities),
() -> "admin"); () -> "admin");
service.setDefault(created.id(), () -> "admin"); ModelService.ModelView second = service.save(
ModelService.ModelSecret secret = service.defaultModelSecret(); null,
new ModelService.ModelInput(
"第二测试模型", "https://second-model.example.test", "model-second", "secret-5678",
Map.of("timeoutSeconds", 90), capabilities),
() -> "admin");
assertThat(created.defaultModel()).isTrue();
assertThat(second.defaultModel()).isFalse();
service.setDefault(second.id(), () -> "admin");
ModelService.ModelSecret firstSecret = service.requireRuntimeModel(created.id());
ModelService.ModelSecret defaultSecret = service.defaultModelSecret();
assertThat(updated.name()).isEqualTo("测试模型更新"); assertThat(updated.name()).isEqualTo("测试模型更新");
assertThat(updated.apiKeyHint()).endsWith("1234"); assertThat(updated.apiKeyHint()).endsWith("1234");
assertThat(secret.apiKey()).isEqualTo("secret-1234"); assertThat(firstSecret.apiKey()).isEqualTo("secret-1234");
assertThat(secret.modelId()).isEqualTo("model-v2"); assertThat(firstSecret.modelId()).isEqualTo("model-v2");
assertThat(secret.contextWindow()).isEqualTo(65_536); assertThat(defaultSecret.apiKey()).isEqualTo("secret-5678");
assertThat(defaultSecret.modelId()).isEqualTo("model-second");
assertThat(defaultSecret.contextWindow()).isEqualTo(65_536);
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id") assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_assignment WHERE model_config_id = :id")
.param("id", created.id()).query(Long.class).single()).isEqualTo(3L); .param("id", second.id()).query(Long.class).single()).isEqualTo(3L);
assertThatThrownBy(() -> service.setEnabled(second.id(), false))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("DEFAULT_MODEL_REQUIRED"));
ModelService.ModelView disabled = service.setEnabled(created.id(), false);
assertThat(disabled.enabled()).isFalse();
// 已完成 Run 仍属于历史审计事实;即使模型已经停用,也必须阻止真删除。
UUID historyProjectId = UUID.randomUUID();
UUID historyRunId = UUID.randomUUID();
jdbc().sql("""
INSERT INTO app.project(id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, '模型历史企业', '模型历史测试', :threadId, 'ADVANCED', :userId)
""").param("id", historyProjectId).param("threadId", "thread-" + historyProjectId)
.param("userId", userId).update();
jdbc().sql("""
INSERT INTO app.agent_run(
id, project_id, model_config_id, trigger_type, status, trace_id, ended_at)
VALUES (:id, :projectId, :modelId, 'INITIAL', 'COMPLETED', :traceId, CURRENT_TIMESTAMP)
""").param("id", historyRunId).param("projectId", historyProjectId)
.param("modelId", created.id()).param("traceId", "trace-" + historyRunId).update();
assertThatThrownBy(() -> service.delete(created.id()))
.isInstanceOfSatisfying(tech.easyflow.manuagent.common.ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_HISTORY_EXISTS"));
// 清理本测试创建的引用后,再验证从未被历史 Run 使用的模型仍可按原有规则删除。
jdbc().sql("DELETE FROM app.agent_run WHERE id = :id").param("id", historyRunId).update();
jdbc().sql("DELETE FROM app.project WHERE id = :id").param("id", historyProjectId).update();
service.delete(created.id());
assertThat(jdbc().sql("SELECT COUNT(*) FROM app.model_config WHERE id = :id")
.param("id", created.id()).query(Long.class).single()).isZero();
} }
/** /**
@@ -442,14 +483,10 @@ class DatabaseAndEventIntegrationTest {
SkillConfigMapper mapper = skillConfigMapper(); SkillConfigMapper mapper = skillConfigMapper();
AppProperties properties = new AppProperties( AppProperties properties = new AppProperties(
temporaryDirectory, temporaryDirectory,
temporaryDirectory.resolve("deepseek.key"),
temporaryDirectory.resolve("dashscope.key"), temporaryDirectory.resolve("dashscope.key"),
"integration-master-key", "integration-master-key",
"admin", "admin",
"admin", "admin",
"https://default.example.test",
"default-model",
131_072,
"runtime:test", "runtime:test",
"bridge", "bridge",
Duration.ofMinutes(1)); Duration.ofMinutes(1));

View File

@@ -28,8 +28,8 @@ class KeyCipherAndShellTest {
private AppProperties properties() { private AppProperties properties() {
return new AppProperties( return new AppProperties(
Path.of("data"), Path.of("deepseek"), Path.of("dashscope"), Path.of("data"), Path.of("dashscope"),
"unit-test-master", "admin", "admin123", "https://api.example.test", "model", "unit-test-master", "admin", "admin123",
131_072, "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1)); "smart-factory-agent-runtime:test", "bridge", Duration.ofMinutes(1));
} }
} }

View File

@@ -33,6 +33,7 @@ import tech.easyflow.manuagent.config.MyBatisFlexConfiguration;
import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.model.KeyCipher; import tech.easyflow.manuagent.model.KeyCipher;
import tech.easyflow.manuagent.model.ModelService; import tech.easyflow.manuagent.model.ModelService;
@@ -91,7 +92,7 @@ class MyBatisFlexTransactionIntegrationTest {
UUID userId = UUID.randomUUID(); UUID userId = UUID.randomUUID();
UUID missingUserId = UUID.randomUUID(); UUID missingUserId = UUID.randomUUID();
UUID currentDefaultId = UUID.randomUUID(); UUID currentDefaultId = UUID.randomUUID();
UUID disabledTargetId = UUID.randomUUID(); UUID enabledTargetId = UUID.randomUUID();
jdbc.sql(""" jdbc.sql("""
INSERT INTO app.app_user(id, username, password_hash, display_name) INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, 'encoded', '事务测试用户') VALUES (:id, :username, 'encoded', '事务测试用户')
@@ -106,12 +107,12 @@ class MyBatisFlexTransactionIntegrationTest {
(:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test', (:currentId, :currentName, 'OPENAI_COMPATIBLE', 'https://current.test',
'current-model', TRUE, TRUE), 'current-model', TRUE, TRUE),
(:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test', (:targetId, :targetName, 'OPENAI_COMPATIBLE', 'https://target.test',
'target-model', FALSE, FALSE) 'target-model', TRUE, FALSE)
""") """)
.param("currentId", currentDefaultId) .param("currentId", currentDefaultId)
.param("currentName", "current-" + currentDefaultId) .param("currentName", "current-" + currentDefaultId)
.param("targetId", disabledTargetId) .param("targetId", enabledTargetId)
.param("targetName", "target-" + disabledTargetId) .param("targetName", "target-" + enabledTargetId)
.update(); .update();
UserService users = context.getBean(UserService.class); UserService users = context.getBean(UserService.class);
when(users.requireUserId("admin")).thenReturn(missingUserId); when(users.requireUserId("admin")).thenReturn(missingUserId);
@@ -119,7 +120,7 @@ class MyBatisFlexTransactionIntegrationTest {
assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1); assertThat(context.getBeansOfType(PlatformTransactionManager.class)).hasSize(1);
assertThat(AopUtils.isAopProxy(modelService)).isTrue(); assertThat(AopUtils.isAopProxy(modelService)).isTrue();
assertThatThrownBy(() -> modelService.setDefault(disabledTargetId, () -> "admin")) assertThatThrownBy(() -> modelService.setDefault(enabledTargetId, () -> "admin"))
.hasRootCauseInstanceOf(PSQLException.class); .hasRootCauseInstanceOf(PSQLException.class);
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id") assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
@@ -127,7 +128,7 @@ class MyBatisFlexTransactionIntegrationTest {
.query(Boolean.class) .query(Boolean.class)
.single()).isTrue(); .single()).isTrue();
assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id") assertThat(jdbc.sql("SELECT is_default FROM app.model_config WHERE id = :id")
.param("id", disabledTargetId) .param("id", enabledTargetId)
.query(Boolean.class) .query(Boolean.class)
.single()).isFalse(); .single()).isFalse();
}); });
@@ -152,15 +153,14 @@ class MyBatisFlexTransactionIntegrationTest {
ModelService modelService( ModelService modelService(
ModelConfigMapper modelMapper, ModelConfigMapper modelMapper,
ModelAssignmentMapper assignmentMapper, ModelAssignmentMapper assignmentMapper,
AppUserMapper userMapper, AgentRunMapper runMapper,
UserService userService) { UserService userService) {
return new ModelService( return new ModelService(
modelMapper, modelMapper,
assignmentMapper, assignmentMapper,
userMapper, runMapper,
userService, userService,
mock(KeyCipher.class), mock(KeyCipher.class),
mock(AppProperties.class),
new ObjectMapper()); new ObjectMapper());
} }
} }

View File

@@ -1,14 +1,62 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
import tech.easyflow.manuagent.skill.SkillService;
/** /**
* 验证 Agent 事件持久化的精简规则。 * 验证 Agent 事件持久化的精简规则。
*/ */
class AgentExecutionServiceTest { class AgentExecutionServiceTest {
/**
* Run 在创建时已经固化模型配置 ID执行和模型重连都必须沿用该 ID
* 不能在工厂内部重新读取可能已经变化的全局默认模型。
*/
@Test
void shouldCreateAgentWithModelBoundToRun() {
UUID projectId = UUID.randomUUID();
UUID runId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentFactory factory = mock(AgentFactory.class);
AgentFactory.AgentHandle handle = mock(AgentFactory.AgentHandle.class);
AguiAgentAdapter adapter = mock(AguiAgentAdapter.class);
SkillService skillService = mock(SkillService.class);
when(handle.adapter()).thenReturn(adapter);
when(adapter.run(any())).thenReturn(Flux.empty());
when(skillService.enabledNames()).thenReturn(new String[] {"document"});
when(factory.create(eq(projectId), eq(modelId), any(String[].class))).thenReturn(handle);
AgentExecutionService service = new AgentExecutionService(
new ObjectMapper(),
factory,
mock(AgentEventService.class),
mock(ProjectFileService.class),
skillService);
ProjectService.ProjectView project = mock(ProjectService.ProjectView.class);
when(project.id()).thenReturn(projectId);
when(project.threadId()).thenReturn("project-" + projectId);
AgentRunService.RunView run = new AgentRunService.RunView(
runId, projectId, modelId, "INITIAL", "RUNNING", null, null, null, null);
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
}
/** /**
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。 * 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
*/ */

View File

@@ -1,20 +1,87 @@
package tech.easyflow.manuagent.agent; package tech.easyflow.manuagent.agent;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.model.transport.HttpTransportException; import io.agentscope.core.model.transport.HttpTransportException;
import io.agentscope.core.skill.AgentSkill; import io.agentscope.core.skill.AgentSkill;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import tech.easyflow.manuagent.artifact.ArtifactService;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.project.ProjectFileService;
import tech.easyflow.manuagent.project.ProjectService;
/** /**
* 验证 Agent Run 的模型重连边界。 * 验证 Agent Run 的模型重连边界。
*/ */
class AgentRunServiceTest { class AgentRunServiceTest {
/**
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
*/
@Test
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdated() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
UUID currentRunId = UUID.randomUUID();
UUID replacementRunId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
ProjectService.ProjectView project = new ProjectService.ProjectView(
projectId, "测试企业", "测试项目", "thread-1", "ADVANCED", "MATERIAL_CHECK", 0L, now, now);
AgentRunService.RunView current = new AgentRunService.RunView(
currentRunId, projectId, modelId, "INITIAL", "RUNNING", null, null, now, null);
AgentRunService.RunView replacement = new AgentRunService.RunView(
replacementRunId, projectId, modelId, "RESUME", "RUNNING", null, null, now, null);
AgentRunMapper runMapper = mock(AgentRunMapper.class);
AgentRunStore runStore = mock(AgentRunStore.class);
ProjectService projectService = mock(ProjectService.class);
UserService userService = mock(UserService.class);
when(projectService.require(projectId)).thenReturn(project);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
when(runStore.latest(projectId)).thenReturn(current);
when(runStore.interruptedPhase(current, project)).thenReturn("MATERIAL_CHECK");
when(runMapper.interruptRunning(currentRunId)).thenReturn(1);
when(runStore.create(projectId, "RESUME", currentRunId, modelId)).thenReturn(replacement);
AgentRunService service = new AgentRunService(
runMapper,
new ObjectMapper(),
mock(AgentExecutionService.class),
mock(AgentOutputService.class),
runStore,
mock(AgentEventService.class),
projectService,
mock(ProjectFileService.class),
userService,
mock(ArtifactService.class),
mock(ExecutorService.class),
mock(TransactionTemplate.class));
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
TransactionSynchronizationManager.initSynchronization();
try {
assertThat(service.switchModel(projectId, modelId, () -> "admin")).isEqualTo(replacement);
verify(runMapper).interruptRunning(currentRunId);
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
/** /**
* 网络故障和服务端错误允许重连,参数错误保持原始失败。 * 网络故障和服务端错误允许重连,参数错误保持原始失败。
*/ */

View File

@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.entity.AgentRunEntity; import tech.easyflow.manuagent.entity.AgentRunEntity;
import tech.easyflow.manuagent.common.ApiException; import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentEventMapper; import tech.easyflow.manuagent.mapper.AgentEventMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper; import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper;
@@ -49,10 +50,11 @@ class AgentRunStoreQueryTest {
} }
/** /**
* 默认模型缺失属于数据库配置异常,不应在 ORM 迁移中新增 409 业务错误 * 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误
* 不能把正常的“尚未配置”状态暴露为服务端技术异常。
*/ */
@Test @Test
void shouldKeepMissingDefaultModelAsUnexpectedTechnicalFailure() { void shouldReportMissingDefaultModelAsConfigurationConflict() {
AgentRunMapper runMapper = mock(AgentRunMapper.class); AgentRunMapper runMapper = mock(AgentRunMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L); when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
AgentRunStore store = new AgentRunStore( AgentRunStore store = new AgentRunStore(
@@ -62,8 +64,49 @@ class AgentRunStoreQueryTest {
new ObjectMapper()); new ObjectMapper());
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null)) assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
.isInstanceOf(IllegalStateException.class) .isInstanceOfSatisfying(ApiException.class, exception -> {
.isNotInstanceOf(ApiException.class); assertThat(exception.status().value()).isEqualTo(409);
assertThat(exception.code()).isEqualTo("MODEL_NOT_CONFIGURED");
});
}
/**
* 用户恢复任务时可以显式选择替代模型;新 Run 必须保存该模型 ID
* 不能再次回退到随后可能变化的全局默认模型。
*/
@Test
void shouldCreateRunWithExplicitEnabledModel() {
UUID projectId = UUID.randomUUID();
UUID modelId = UUID.randomUUID();
AgentRunMapper runMapper = mock(AgentRunMapper.class);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
ModelConfigEntity selectedModel = new ModelConfigEntity();
selectedModel.setId(modelId);
selectedModel.setEnabled(true);
when(modelMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(selectedModel);
AgentRunEntity storedRun = new AgentRunEntity();
storedRun.setId(UUID.randomUUID());
storedRun.setProjectId(projectId);
storedRun.setModelConfigId(modelId);
storedRun.setTriggerType("RESUME");
storedRun.setStatus("RUNNING");
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(storedRun);
AgentRunStore store = new AgentRunStore(
runMapper,
mock(AgentEventMapper.class),
modelMapper,
new ObjectMapper());
AgentRunService.RunView run = store.create(projectId, "RESUME", null, modelId);
ArgumentCaptor<AgentRunEntity> entityCaptor = ArgumentCaptor.forClass(AgentRunEntity.class);
verify(runMapper).insertSelective(entityCaptor.capture());
assertThat(entityCaptor.getValue().getModelConfigId()).isEqualTo(modelId);
assertThat(run.modelConfigId()).isEqualTo(modelId);
} }
/** /**

View File

@@ -0,0 +1,72 @@
package tech.easyflow.manuagent.config;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.context.annotation.Configuration;
/**
* 验证应用主密钥沿用本地配置文件的读取方式。
*/
class AppPropertiesValidationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(PropertiesConfiguration.class)
.withPropertyValues(
"app.data-root=file:../data",
"app.dashscope-key-file=./dashscope_key.txt",
"app.master-key=unit-test-master-key",
"app.admin-username=admin",
"app.admin-password=admin123",
"app.sandbox-image=runtime:test",
"app.sandbox-network=bridge",
"app.run-timeout=1m");
/**
* 默认应用配置必须直接提供主密钥,使本地启动不依赖额外环境变量。
*
* @throws IOException application.yml 无法读取时抛出
*/
@Test
void shouldProvideMasterKeyInApplicationConfiguration() throws IOException {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
List<PropertySource<?>> sources = loader.load(
"application.yml",
new ClassPathResource("application.yml"));
assertThat(sources)
.extracting(source -> source.getProperty("app.master-key"))
.singleElement()
.isInstanceOf(String.class)
.asString()
.isNotBlank()
.doesNotContain("APP_MASTER_KEY");
}
/**
* 提供非空加密主密钥后,配置属性应可以正常绑定并供密钥组件使用。
*/
@Test
void shouldBindConfiguredMasterKey() {
contextRunner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBean(AppProperties.class).masterKey())
.isEqualTo("unit-test-master-key");
});
}
/**
* 注册应用配置属性,复用生产环境的 Spring Boot 配置绑定流程。
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(AppProperties.class)
static class PropertiesConfiguration {
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.manuagent.model;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* 验证模型管理页面依赖的 HTTP 接口契约。
*
* <p>这些测试刻意放在控制器边界,防止前端请求方法或路径与后端映射再次发生漂移。</p>
*/
class ModelControllerTest {
/**
* 测试连接必须接收当前表单草稿,使用户无需先持久化可能无效的配置。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldTestCurrentModelDraftWithoutSavingIt() throws Exception {
ModelService service = mock(ModelService.class);
when(service.test(any(ModelService.ConnectionTestInput.class)))
.thenReturn(new ModelService.ConnectionResult(true, 12L, "连接正常"));
MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build();
mvc.perform(post("/api/models/test")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"id": "01cdd509-a79a-4503-839f-160b32d518e2",
"baseUrl": "https://draft.example.test/v1",
"modelId": "draft-model",
"apiKey": "draft-secret"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("连接正常"));
verify(service).test(new ModelService.ConnectionTestInput(
UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2"),
"https://draft.example.test/v1",
"draft-model",
"draft-secret"));
}
/**
* 停用操作必须由 PATCH 状态接口处理,不能落入静态资源处理器并返回 404。
*
* @throws Exception MockMvc 执行失败时抛出
*/
@Test
void shouldRouteDisableRequestToModelService() throws Exception {
UUID id = UUID.fromString("01cdd509-a79a-4503-839f-160b32d518e2");
ModelService service = mock(ModelService.class);
MockMvc mvc = MockMvcBuilders.standaloneSetup(new ModelController(service)).build();
mvc.perform(patch("/api/models/{id}/enabled", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":false}"))
.andExpect(status().isOk());
verify(service).setEnabled(id, false);
}
}

View File

@@ -0,0 +1,201 @@
package tech.easyflow.manuagent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
/**
* 验证模型草稿连接测试的密钥选择和外部请求边界。
*/
class ModelServiceConnectionTest {
/**
* Spring 容器必须能够在生产构造器和包内测试构造器之间选择生产构造器。
*
* <p>该测试防止新增辅助构造器后,应用启动阶段退化为查找不存在的无参构造器。</p>
*/
@Test
void shouldCreateModelServiceBeanWithProductionConstructor() {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(ModelConfigMapper.class, () -> mock(ModelConfigMapper.class));
context.registerBean(ModelAssignmentMapper.class, () -> mock(ModelAssignmentMapper.class));
context.registerBean(AgentRunMapper.class, () -> mock(AgentRunMapper.class));
context.registerBean(UserService.class, () -> mock(UserService.class));
context.registerBean(KeyCipher.class, () -> mock(KeyCipher.class));
context.registerBean(ObjectMapper.class, () -> new ObjectMapper());
context.register(ModelService.class);
context.refresh();
assertThat(context.getBean(ModelService.class)).isNotNull();
}
}
/**
* 表单提供新密钥时,应直接测试草稿地址,且不能读取或修改数据库模型配置。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldTestDraftWithSubmittedKeyWithoutReadingDatabase() throws Exception {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
UUID.randomUUID(),
"https://draft.example.test/v1/",
"draft-model",
"draft-secret"));
assertThat(result.success()).isTrue();
HttpRequest request = sentRequest(httpClient);
assertThat(request.uri()).isEqualTo(URI.create("https://draft.example.test/v1/chat/completions"));
assertThat(request.headers().firstValue("Authorization")).contains("Bearer draft-secret");
verifyNoInteractions(modelMapper);
}
/**
* 新模型没有数据库 ID 和保存密钥API Key 留空时应在发起网络请求前返回明确错误。
*/
@Test
void shouldRequireKeyWhenTestingNewModelDraft() {
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
null,
"https://draft.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("测试新模型需要 API Key");
verifyNoInteractions(modelMapper, httpClient);
}
/**
* 草稿改到其他 API 主机时不能转发数据库中的隐藏密钥,必须要求用户重新输入 Key。
*/
@Test
void shouldNotForwardStoredKeyToChangedBaseUrl() {
UUID modelId = UUID.randomUUID();
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext("encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8));
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
HttpClient httpClient = mock(HttpClient.class);
ModelService service = service(modelMapper, mock(KeyCipher.class), httpClient);
assertThatThrownBy(() -> service.test(new ModelService.ConnectionTestInput(
modelId,
"https://attacker.example.test/v1",
"draft-model",
"")))
.isInstanceOf(ApiException.class)
.hasMessage("API 地址变更后需要重新输入 API Key");
verifyNoInteractions(httpClient);
}
/**
* 草稿地址与保存地址一致时,允许安全复用保存密钥,避免用户为普通模型参数调整重复输入 Key。
*
* @throws Exception HTTP 客户端桩配置或调用失败时抛出
*/
@Test
void shouldReuseStoredKeyWhenBaseUrlIsUnchanged() throws Exception {
UUID modelId = UUID.randomUUID();
byte[] ciphertext = "encrypted-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8);
ModelConfigEntity saved = new ModelConfigEntity();
saved.setId(modelId);
saved.setBaseUrl("https://saved.example.test/v1");
saved.setApiKeyCiphertext(ciphertext);
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
when(modelMapper.selectOneByQuery(any())).thenReturn(saved);
KeyCipher keyCipher = mock(KeyCipher.class);
when(keyCipher.decrypt(ciphertext)).thenReturn("stored-secret");
HttpClient httpClient = successfulHttpClient();
ModelService service = service(modelMapper, keyCipher, httpClient);
ModelService.ConnectionResult result = service.test(new ModelService.ConnectionTestInput(
modelId,
"https://saved.example.test/v1/",
"updated-model-id",
""));
assertThat(result.success()).isTrue();
assertThat(sentRequest(httpClient).headers().firstValue("Authorization"))
.contains("Bearer stored-secret");
}
/**
* 创建仅替换外部依赖的模型服务,生产逻辑仍由真实 {@link ModelService} 执行。
*
* @param modelMapper 模型配置 Mapper 桩
* @param keyCipher 密钥组件桩
* @param httpClient HTTP 客户端桩
* @return 待测试模型服务
*/
private ModelService service(ModelConfigMapper modelMapper, KeyCipher keyCipher, HttpClient httpClient) {
return new ModelService(
modelMapper,
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
mock(UserService.class),
keyCipher,
new ObjectMapper(),
httpClient);
}
/**
* 创建固定返回 HTTP 200 的客户端,避免测试访问真实模型服务。
*
* @return HTTP 客户端桩
* @throws Exception 配置泛型 send 方法桩时抛出
*/
@SuppressWarnings("unchecked")
private HttpClient successfulHttpClient() throws Exception {
HttpClient client = mock(HttpClient.class);
HttpResponse<String> response = mock(HttpResponse.class);
when(response.statusCode()).thenReturn(200);
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(response);
return client;
}
/**
* 捕获服务发送的请求,以结果状态而非内部调用顺序验证连接目标和认证头。
*
* @param client HTTP 客户端桩
* @return 捕获到的请求
* @throws Exception Mockito 验证泛型 send 方法时抛出
*/
@SuppressWarnings("unchecked")
private HttpRequest sentRequest(HttpClient client) throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
org.mockito.Mockito.verify(client).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}

View File

@@ -12,6 +12,7 @@ import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.ApplicationRunner;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import tech.easyflow.manuagent.auth.UserService; import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.config.AppProperties; import tech.easyflow.manuagent.config.AppProperties;
@@ -19,12 +20,21 @@ import tech.easyflow.manuagent.entity.ModelConfigEntity;
import tech.easyflow.manuagent.mapper.AppUserMapper; import tech.easyflow.manuagent.mapper.AppUserMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper; import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper; import tech.easyflow.manuagent.mapper.ModelConfigMapper;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
/** /**
* 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。 * 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。
*/ */
class ModelServiceQueryTest { class ModelServiceQueryTest {
/**
* 模型必须完全由管理接口和数据库维护,服务启动不得再通过 Key 文件自动灌入默认模型。
*/
@Test
void shouldNotInitializeModelConfigurationFromApplicationFiles() {
assertThat(ApplicationRunner.class.isAssignableFrom(ModelService.class)).isFalse();
}
/** /**
* 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。 * 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。
*/ */
@@ -36,10 +46,9 @@ class ModelServiceQueryTest {
ModelService service = new ModelService( ModelService service = new ModelService(
modelMapper, modelMapper,
mock(ModelAssignmentMapper.class), mock(ModelAssignmentMapper.class),
mock(AppUserMapper.class), mock(AgentRunMapper.class),
mock(UserService.class), mock(UserService.class),
mock(KeyCipher.class), mock(KeyCipher.class),
mock(AppProperties.class),
new ObjectMapper()); new ObjectMapper());
List<ModelService.ModelView> models = service.list(); List<ModelService.ModelView> models = service.list();

View File

@@ -0,0 +1,93 @@
package tech.easyflow.manuagent.model;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import tech.easyflow.manuagent.auth.UserService;
import tech.easyflow.manuagent.common.ApiException;
import tech.easyflow.manuagent.mapper.AgentRunMapper;
import tech.easyflow.manuagent.mapper.ModelAssignmentMapper;
import tech.easyflow.manuagent.mapper.ModelConfigMapper;
/**
* 验证模型配置在进入数据库和外部 HTTP 客户端之前具有明确、可审计的输入边界。
*/
class ModelServiceValidationTest {
/**
* 验证名称、地址、模型标识和 API Key 的长度限制,防止超长请求占用内存或触发数据库截断异常。
*/
@Test
void shouldBoundModelInputTextFields() {
Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
ModelService.ModelInput input = new ModelService.ModelInput(
"".repeat(101),
"https://example.test/" + "a".repeat(500),
"m".repeat(256),
"k".repeat(4097),
Map.of(),
Map.of("contextWindow", 8_192));
// 一次构造四个越界字段,并按属性名断言,确保每个 HTTP 入参都真正受到约束。
Set<String> invalidProperties = validator.validate(input).stream()
.map(violation -> violation.getPropertyPath().toString())
.collect(Collectors.toSet());
assertThat(invalidProperties).containsExactlyInAnyOrder("name", "baseUrl", "modelId", "apiKey");
}
/**
* 验证模型地址拒绝非 HTTP(S) 协议、缺失主机以及可能改变请求语义的用户信息、查询和片段。
*/
@Test
void shouldRejectUnsafeOrAmbiguousBaseUrls() {
ModelService service = service();
for (String baseUrl : Set.of(
"file:///etc/passwd",
"https:///v1",
"https://user:password@example.test/v1",
"https://example.test/v1?tenant=other",
"https://example.test/v1#fragment")) {
ModelService.ModelInput input = new ModelService.ModelInput(
"测试模型",
baseUrl,
"model-v1",
"secret",
Map.of(),
Map.of("contextWindow", 8_192));
assertThatThrownBy(() -> service.save(null, input, () -> "admin"))
.as("地址应被拒绝:%s", baseUrl)
.isInstanceOfSatisfying(ApiException.class, exception ->
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
}
}
/**
* 创建只执行输入校验所需路径的模型服务;无效 URL 应在任何持久化写入之前被拒绝。
*
* @return 配置了当前用户的模型服务
*/
private ModelService service() {
UserService userService = mock(UserService.class);
when(userService.requireUserId("admin")).thenReturn(UUID.randomUUID());
return new ModelService(
mock(ModelConfigMapper.class),
mock(ModelAssignmentMapper.class),
mock(AgentRunMapper.class),
userService,
mock(KeyCipher.class),
new ObjectMapper());
}
}

View File

@@ -38,9 +38,9 @@ class ProjectFileServiceTest {
@Test @Test
void shouldDeleteProjectWorkspace() throws Exception { void shouldDeleteProjectWorkspace() throws Exception {
AppProperties properties = new AppProperties( AppProperties properties = new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model", "test-master", "admin", "admin",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); "runtime:test", "bridge", Duration.ofMinutes(1));
ProjectFileService service = new ProjectFileService( ProjectFileService service = new ProjectFileService(
mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties); mock(ProjectFileMapper.class), mock(UserService.class), mock(ProjectService.class), properties);
UUID projectId = UUID.randomUUID(); UUID projectId = UUID.randomUUID();
@@ -121,8 +121,8 @@ class ProjectFileServiceTest {
*/ */
private AppProperties properties() { private AppProperties properties() {
return new AppProperties( return new AppProperties(
temporaryDirectory, Path.of("deepseek"), Path.of("dashscope"), temporaryDirectory, Path.of("dashscope"),
"test-master", "admin", "admin", "https://example.test", "model", "test-master", "admin", "admin",
131_072, "runtime:test", "bridge", Duration.ofMinutes(1)); "runtime:test", "bridge", Duration.ofMinutes(1));
} }
} }

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>智造申报 Agent</title> <title>智造申报 Agent</title>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1769e8"/>
<path d="M7 9h7l2 2h9v12H7z" fill="none" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

View File

@@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModelsPage from './ModelsPage.vue'
const apiMock = vi.fn()
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), error: vi.fn() },
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) }
}))
vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args)
}))
describe('ModelsPage', () => {
beforeEach(() => {
// 避免把 mockReset() 返回的 mock 函数误交给 Vitest 作为测试清理回调。
apiMock.mockReset()
})
/** 统一注册页面使用的 Element Plus 浅层桩,测试输出不应包含组件解析警告。 */
function mountModelsPage() {
return shallowMount(ModelsPage, {
global: {
renderStubDefaultSlot: true,
stubs: {
'el-button': true,
'el-input': true,
'el-input-number': true
}
}
})
}
it('数据库为空时提供新增模型入口', async () => {
apiMock.mockResolvedValueOnce([])
const wrapper = mountModelsPage()
await flushPromises()
expect(wrapper.text()).toContain('新增模型')
expect(wrapper.text()).toContain('暂无模型')
})
it('展示多个 OpenAI 兼容模型及其状态', async () => {
apiMock.mockResolvedValueOnce([
{
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: true
},
{
id: 'model-b', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://b.example.test', modelId: 'model-b', apiKeyHint: '••••5678',
configJson: '{}', capabilitiesJson: '{"contextWindow":131072}', enabled: false, defaultModel: false
}
])
const wrapper = mountModelsPage()
await flushPromises()
expect(wrapper.text()).toContain('编排模型')
expect(wrapper.text()).toContain('备用模型')
expect(wrapper.text()).toContain('OpenAI 兼容')
expect(wrapper.text()).toContain('已停用')
expect(wrapper.text()).not.toContain('DeepSeek')
})
it('测试连接时提交当前表单草稿而不是数据库旧配置', async () => {
apiMock
.mockResolvedValueOnce([{
id: 'model-a', name: '编排模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://old.example.test/v1', modelId: 'old-model', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
}])
.mockResolvedValueOnce({ success: true, latencyMs: 12, message: '连接正常' })
const wrapper = mountModelsPage()
await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
// 依次修改 API 地址、API Key 和模型 ID确保请求使用尚未保存的表单值。
inputs[2].vm.$emit('update:modelValue', 'https://draft.example.test/v1')
inputs[3].vm.$emit('update:modelValue', 'draft-secret')
inputs[4].vm.$emit('update:modelValue', 'draft-model')
await wrapper.vm.$nextTick()
const testButton = wrapper.findAllComponents({ name: 'ElButton' })
.find(button => button.text() === '测试连接')
await testButton!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/test', {
method: 'POST',
body: JSON.stringify({
id: 'model-a',
baseUrl: 'https://draft.example.test/v1',
modelId: 'draft-model',
apiKey: 'draft-secret'
})
})
expect(wrapper.text()).toContain('连接正常')
// 成功标记只对应发起请求时的草稿,继续编辑后必须立即失效。
inputs[4].vm.$emit('update:modelValue', 'changed-after-test')
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('连接正常')
})
it('停用模型时调用 PATCH 启用状态接口', async () => {
apiMock
.mockResolvedValueOnce([{
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: true, defaultModel: false
}])
.mockResolvedValueOnce({
id: 'model-a', name: '备用模型', provider: 'OPENAI_COMPATIBLE',
baseUrl: 'https://a.example.test/v1', modelId: 'model-a', apiKeyHint: '••••1234',
configJson: '{}', capabilitiesJson: '{"contextWindow":65536}', enabled: false, defaultModel: false
})
.mockResolvedValueOnce([])
const wrapper = mountModelsPage()
await flushPromises()
const disableButton = wrapper.findAllComponents({ name: 'ElButton' })
.find(button => button.text() === '停用')
await disableButton!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/models/model-a/enabled', {
method: 'PATCH',
body: JSON.stringify({ enabled: false })
})
})
})

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CircleCheck } from '@element-plus/icons-vue' import { CircleCheck, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { api } from '../api' import { api } from '../api'
interface ModelConfig { interface ModelConfig {
@@ -19,19 +19,38 @@ interface ModelConfig {
const models = ref<ModelConfig[]>([]) const models = ref<ModelConfig[]>([])
const selectedId = ref('') const selectedId = ref('')
const creating = ref(false)
const saving = ref(false) const saving = ref(false)
const testing = ref(false) const testing = ref(false)
const tested = ref(false) const tested = ref(false)
const stateChanging = ref(false)
const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 }) const form = reactive({ name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
const selected = computed(() => models.value.find(model => model.id === selectedId.value)) const selected = computed(() => models.value.find(model => model.id === selectedId.value))
const canSave = computed(() => Boolean(
form.name.trim() && form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
))
const canTest = computed(() => Boolean(
form.baseUrl.trim() && form.modelId.trim() && (!creating.value || form.apiKey.trim())
))
async function load() { // “连接正常”只证明发起请求时的草稿;任一字段变化后必须重新测试。
watch(form, () => { tested.value = false })
/** 从服务端刷新模型列表,并尽量维持用户当前选中的模型。 */
async function load(preferredId?: string) {
models.value = await api<ModelConfig[]>('/api/models') models.value = await api<ModelConfig[]>('/api/models')
select(models.value.find(model => model.defaultModel)?.id || models.value[0]?.id || '') const nextId = preferredId
|| (models.value.some(model => model.id === selectedId.value) ? selectedId.value : '')
|| models.value.find(model => model.defaultModel)?.id
|| models.value[0]?.id
|| ''
if (nextId) select(nextId)
} }
/** 将数据库模型投影到编辑表单API Key 始终保持为空,避免密钥回显。 */
function select(id: string) { function select(id: string) {
selectedId.value = id selectedId.value = id
creating.value = false
const model = models.value.find(item => item.id === id) const model = models.value.find(item => item.id === id)
if (!model) return if (!model) return
let capabilities: { contextWindow?: number } = {} let capabilities: { contextWindow?: number } = {}
@@ -46,32 +65,57 @@ function select(id: string) {
tested.value = false tested.value = false
} }
/** 进入新增模式并清空所有可能来自已有模型的可编辑字段。 */
function beginCreate() {
selectedId.value = ''
creating.value = true
tested.value = false
Object.assign(form, { name: '', baseUrl: '', modelId: '', apiKey: '', contextWindow: 131072 })
}
/** 创建或更新模型;新增模型的默认选择由后端事务保证。 */
async function save() { async function save() {
if (!canSave.value || saving.value) return
saving.value = true saving.value = true
try { try {
await api(`/api/models/${selectedId.value}`, { const path = creating.value ? '/api/models' : `/api/models/${selectedId.value}`
method: 'PUT', const saved = await api<ModelConfig>(path, {
method: creating.value ? 'POST' : 'PUT',
body: JSON.stringify({ body: JSON.stringify({
name: form.name, name: form.name.trim(),
baseUrl: form.baseUrl, baseUrl: form.baseUrl.trim(),
modelId: form.modelId, modelId: form.modelId.trim(),
apiKey: form.apiKey, apiKey: form.apiKey,
config: { timeoutSeconds: 120, reasoningEffort: 'high' }, config: { timeoutSeconds: 120, reasoningEffort: 'high' },
capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow } capabilities: { toolCalling: true, reasoning: true, contextWindow: form.contextWindow }
}) })
}) })
ElMessage.success('已保存') ElMessage.success(creating.value ? '模型已新增' : '配置已保存')
await load() creating.value = false
await load(saved.id)
} finally { } finally {
saving.value = false saving.value = false
} }
} }
async function test() { /**
* 使用当前表单草稿发送最小请求;已有模型留空 API Key 时由后端安全复用保存密钥。
* 测试只验证草稿,不会隐式保存任何配置字段。
*/
async function testConnection() {
if (!canTest.value || testing.value) return
testing.value = true testing.value = true
tested.value = false tested.value = false
try { try {
await api(`/api/models/${selectedId.value}/test`, { method: 'POST' }) await api('/api/models/test', {
method: 'POST',
body: JSON.stringify({
id: selected.value?.id || null,
baseUrl: form.baseUrl.trim(),
modelId: form.modelId.trim(),
apiKey: form.apiKey
})
})
tested.value = true tested.value = true
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '连接失败') ElMessage.error(error instanceof Error ? error.message : '连接失败')
@@ -80,43 +124,139 @@ async function test() {
} }
} }
/** 将启用模型设为之后新建 Run 使用的全局默认模型。 */
async function setDefault() { async function setDefault() {
await api(`/api/models/${selectedId.value}/default`, { method: 'POST' }) if (!selected.value || !selected.value.enabled || stateChanging.value) return
await load() stateChanging.value = true
try {
await api(`/api/models/${selected.value.id}/default`, { method: 'POST' })
await load(selected.value.id)
} finally {
stateChanging.value = false
}
} }
onMounted(load) /** 启停非默认模型;后端会阻止停用仍被运行中任务使用的模型。 */
async function toggleEnabled() {
if (!selected.value || stateChanging.value) return
const enabled = !selected.value.enabled
if (!enabled) {
try {
await ElMessageBox.confirm(`停用“${selected.value.name}”?`, '停用模型', {
confirmButtonText: '停用', cancelButtonText: '取消', type: 'warning'
})
} catch {
return
}
}
stateChanging.value = true
try {
const updated = await api<ModelConfig>(`/api/models/${selected.value.id}/enabled`, {
method: 'PATCH', body: JSON.stringify({ enabled })
})
ElMessage.success(enabled ? '模型已启用' : '模型已停用')
await load(updated.id)
} finally {
stateChanging.value = false
}
}
/** 真删除没有历史 Run 引用的非默认模型;常规模型下线优先使用停用。 */
async function removeModel() {
if (!selected.value || stateChanging.value) return
try {
await ElMessageBox.confirm(`永久删除“${selected.value.name}”?`, '删除模型', {
confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning'
})
} catch {
return
}
stateChanging.value = true
try {
await api(`/api/models/${selected.value.id}`, { method: 'DELETE' })
ElMessage.success('模型已删除')
selectedId.value = ''
await load()
if (!models.value.length) beginCreate()
} finally {
stateChanging.value = false
}
}
onMounted(async () => {
await load()
if (!models.value.length) beginCreate()
})
</script> </script>
<template> <template>
<section class="settings-page"> <section class="settings-page">
<header><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></header> <header class="settings-header">
<div><h1>模型配置</h1><p>配置 Agent 运行时使用的模型</p></div>
<el-button :icon="Plus" type="primary" @click="beginCreate">新增模型</el-button>
</header>
<div class="settings-grid"> <div class="settings-grid">
<aside class="settings-list"> <aside class="settings-list">
<h2>已配置模型</h2> <h2>已配置模型 <small>{{ models.length }}</small></h2>
<div v-if="!models.length" class="model-empty">暂无模型</div>
<button <button
v-for="model in models" v-for="model in models"
:key="model.id" :key="model.id"
:class="{ selected: selectedId === model.id }" class="model-list-item"
:class="{ selected: selectedId === model.id, disabled: !model.enabled }"
@click="select(model.id)" @click="select(model.id)"
> >
<strong>{{ model.name }}</strong> <strong>{{ model.name }}</strong>
<span>DeepSeek · {{ model.modelId }}</span> <span>OpenAI 兼容 · {{ model.modelId }}</span>
<small><i></i>可用</small> <small :class="{ muted: !model.enabled }">
<i></i>{{ model.defaultModel ? '默认' : model.enabled ? '已启用' : '已停用' }}
</small>
</button> </button>
</aside> </aside>
<form v-if="selected" class="settings-form" @submit.prevent="save">
<div class="form-title"><h2>{{ selected.name }}</h2><el-button v-if="!selected.defaultModel" @click="setDefault">设为默认</el-button><span v-else class="tag blue">默认</span></div> <form v-if="creating || selected" class="settings-form" @submit.prevent="save">
<label><span>服务商</span><el-input model-value="DeepSeek" disabled /></label> <div class="form-title">
<label><span>API 地址</span><el-input v-model="form.baseUrl" /></label> <h2>{{ creating ? '新增模型' : selected?.name }}</h2>
<label><span>API Key</span><el-input v-model="form.apiKey" type="password" show-password :placeholder="selected.apiKeyHint" /></label> <div v-if="selected" class="model-title-actions">
<label><span>模型 ID</span><el-input v-model="form.modelId" /></label> <span v-if="selected.defaultModel" class="tag blue">默认</span>
<el-button v-else :disabled="!selected.enabled" :loading="stateChanging" @click="setDefault">设为默认</el-button>
</div>
</div>
<label><span>配置名称</span><el-input v-model="form.name" maxlength="100" /></label>
<label><span>服务商</span><el-input model-value="OpenAI 兼容" disabled /></label>
<label><span>API 地址</span><el-input v-model="form.baseUrl" maxlength="500" /></label>
<label>
<span>API Key</span>
<el-input
v-model="form.apiKey"
type="password"
maxlength="4096"
show-password
:placeholder="creating ? '输入 API Key' : selected?.apiKeyHint || '留空保留现有密钥'"
/>
</label>
<label><span>模型 ID</span><el-input v-model="form.modelId" maxlength="255" /></label>
<label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label> <label><span>上下文窗口</span><el-input-number v-model="form.contextWindow" :min="8192" :step="8192" controls-position="right" /></label>
<div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div> <div class="capability-row"><span>能力</span><div><b>工具调用</b><b>推理</b><b>长上下文</b></div></div>
<div class="model-actions"> <div class="model-actions">
<el-button native-type="submit" type="primary" :loading="saving">保存配置</el-button> <el-button native-type="submit" type="primary" :loading="saving" :disabled="!canSave">{{ creating ? '创建模型' : '保存配置' }}</el-button>
<el-button :loading="testing" @click="test">测试连接</el-button> <el-button :loading="testing" :disabled="!canTest" @click="testConnection">测试连接</el-button>
<span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span> <span v-if="tested" class="connection-ok"><CircleCheck />连接正常</span>
<div v-if="selected" class="model-danger-actions">
<el-button :loading="stateChanging" :disabled="selected.defaultModel" @click="toggleEnabled">
{{ selected.enabled ? '停用' : '启用' }}
</el-button>
<el-button
:icon="Delete"
circle
type="danger"
plain
title="删除模型"
:loading="stateChanging"
:disabled="selected.defaultModel"
@click="removeModel"
/>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { defineComponent, h } from 'vue'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ProjectPage from './ProjectPage.vue'
const apiMock = vi.fn()
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { id: 'project-1' } }),
useRouter: () => ({ replace: vi.fn() })
}))
vi.mock('../api', () => ({
api: (...args: unknown[]) => apiMock(...args),
streamEvents: vi.fn(() => vi.fn())
}))
vi.mock('../eventCache', () => ({
cacheEvents: vi.fn(async () => undefined),
deleteCachedEvents: vi.fn(async () => undefined),
readCachedEvents: vi.fn(async () => [])
}))
vi.mock('element-plus', () => ({
ElMessage: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
ElMessageBox: { confirm: vi.fn() }
}))
// 测试只关心项目页发送的模型选择请求,因此用原生控件模拟 Element Plus 的 v-model 契约。
const ElButtonStub = defineComponent({
inheritAttrs: false,
setup(_, { attrs, emit, slots }) {
return () => h('button', { ...attrs, onClick: () => emit('click') }, slots.default?.())
}
})
const ElDialogStub = defineComponent({
props: { modelValue: Boolean },
setup(props, { slots }) {
return () => props.modelValue
? h('div', { role: 'dialog' }, [slots.default?.(), h('footer', slots.footer?.())])
: null
}
})
const ElSelectStub = defineComponent({
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () => h('select', {
value: props.modelValue,
onChange: (event: Event) => emit('update:modelValue', (event.target as HTMLSelectElement).value)
}, slots.default?.())
}
})
const ElOptionStub = defineComponent({
props: { label: String, value: String },
setup(props) {
return () => h('option', { value: props.value }, props.label)
}
})
const enabledModels = [
{ id: 'model-a', name: '当前模型', modelId: 'model-a', enabled: true, defaultModel: false },
{ id: 'model-b', name: '默认模型', modelId: 'model-b', enabled: true, defaultModel: true },
{ id: 'model-c', name: '停用模型', modelId: 'model-c', enabled: false, defaultModel: false }
]
function mountProject(runStatus: 'RUNNING' | 'INTERRUPTED') {
apiMock.mockImplementation(async (url: string, options?: RequestInit) => {
if (url === '/api/projects/project-1') {
return {
id: 'project-1', companyName: '测试企业', projectName: '申报项目', threadId: 'thread-1',
applicationLevel: 'ADVANCED', status: 'WRITING', createdAt: '', updatedAt: ''
}
}
if (url === '/api/projects/project-1/files' || url === '/api/projects/project-1/artifacts'
|| url.startsWith('/api/projects/project-1/events')) return []
if (url === '/api/projects/project-1/plan') return null
if (url === '/api/projects/project-1/runs/latest') {
return { status: runStatus, modelConfigId: 'model-a' }
}
if (url === '/api/models') return enabledModels
if (options?.method === 'POST') return { status: 'RUNNING' }
throw new Error(`未处理的测试请求:${url}`)
})
return shallowMount(ProjectPage, {
global: {
stubs: {
'el-button': ElButtonStub,
'el-dialog': ElDialogStub,
'el-select': ElSelectStub,
'el-option': ElOptionStub,
'el-icon': defineComponent({ setup: (_, { slots }) => () => h('span', slots.default?.()) }),
'el-upload': defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
}
}
})
}
describe('ProjectPage 模型切换', () => {
beforeEach(() => {
// Vitest 会把钩子返回的函数当作清理回调,因此这里不能直接返回 mockReset() 的返回值。
apiMock.mockReset()
})
it('中断任务继续时允许选择启用模型并发送模型 ID', async () => {
const wrapper = mountProject('INTERRUPTED')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '继续')!.trigger('click')
await flushPromises()
expect(wrapper.text()).not.toContain('停用模型')
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '继续运行')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/resume', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
it('运行中的任务可以选择替代模型并调用受控切换接口', async () => {
const wrapper = mountProject('RUNNING')
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '切换模型')!.trigger('click')
await flushPromises()
await wrapper.get('select').setValue('model-b')
await wrapper.findAll('button').find(button => button.text() === '确认切换')!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith('/api/projects/project-1/runs/switch-model', {
method: 'POST', body: JSON.stringify({ modelConfigId: 'model-b' })
})
})
})

View File

@@ -27,6 +27,12 @@ const deleting = ref(false)
const historyLoading = ref(true) const historyLoading = ref(true)
const streamError = ref('') const streamError = ref('')
const showBackToBottom = ref(false) const showBackToBottom = ref(false)
const currentModelConfigId = ref('')
const modelPickerVisible = ref(false)
const modelPickerLoading = ref(false)
const modelPickerMode = ref<'resume' | 'switch'>('resume')
const selectedModelConfigId = ref('')
const selectableModels = ref<Array<{ id: string; name: string; modelId: string; enabled: boolean; defaultModel: boolean }>>([])
let stopStream: (() => void) | null = null let stopStream: (() => void) | null = null
let loadVersion = 0 let loadVersion = 0
const folderInput = ref<HTMLInputElement | null>(null) const folderInput = ref<HTMLInputElement | null>(null)
@@ -36,6 +42,8 @@ const waitingPlan = computed(() => pendingAsk.value?.kind === 'planning' && plan
const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check') const waitingMaterials = computed(() => pendingAsk.value?.kind === 'material_check')
const running = computed(() => runStatus.value === 'RUNNING') const running = computed(() => runStatus.value === 'RUNNING')
const interrupted = computed(() => runStatus.value === 'INTERRUPTED') const interrupted = computed(() => runStatus.value === 'INTERRUPTED')
const modelPickerTitle = computed(() => modelPickerMode.value === 'switch' ? '切换运行模型' : '选择继续运行的模型')
const modelPickerConfirmText = computed(() => modelPickerMode.value === 'switch' ? '确认切换' : '继续运行')
const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级') const levelLabel = computed(() => project.value?.applicationLevel === 'EXCELLENT' ? '卓越级' : '先进级')
const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({ const statusLabel = computed(() => running.value ? '运行中' : interrupted.value ? '已停止' : pendingAsk.value ? '等待确认' : ({
MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档' MATERIAL_CHECK: '材料检验', PLANNING: '规划确认', WRITING: '运行中', DELIVERED: '已完成', FAILED: '执行失败', ARCHIVED: '已归档'
@@ -55,7 +63,7 @@ async function load(id: string) {
api<ProjectFile[]>(`/api/projects/${id}/files`), api<ProjectFile[]>(`/api/projects/${id}/files`),
api<Artifact[]>(`/api/projects/${id}/artifacts`), api<Artifact[]>(`/api/projects/${id}/artifacts`),
api<PlanView | null>(`/api/projects/${id}/plan`), api<PlanView | null>(`/api/projects/${id}/plan`),
api<{ status: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`) api<{ status: string; modelConfigId?: string; pendingInterrupt?: string } | null>(`/api/projects/${id}/runs/latest`)
]) ])
const loadedEvents = await fetchMissingEvents(id, cached) const loadedEvents = await fetchMissingEvents(id, cached)
if (version !== loadVersion || id !== projectId.value) return if (version !== loadVersion || id !== projectId.value) return
@@ -65,6 +73,7 @@ async function load(id: string) {
plan.value = loadedPlan plan.value = loadedPlan
events.value = loadedEvents events.value = loadedEvents
runStatus.value = latest?.status || '' runStatus.value = latest?.status || ''
currentModelConfigId.value = latest?.modelConfigId || ''
pendingAsk.value = parseAsk(latest?.pendingInterrupt) pendingAsk.value = parseAsk(latest?.pendingInterrupt)
startStream(id, version) startStream(id, version)
} finally { } finally {
@@ -234,14 +243,51 @@ async function stopRun() {
} }
} }
async function resumeRun() { /**
if (!interrupted.value || controlLoading.value) return * 打开恢复或切换模型对话框,并从数据库重新读取当前启用的模型。
* 优先保留 Run 已绑定的模型;如果该模型已停用,则退回当前默认模型或首个可用模型。
*/
async function openModelPicker(mode: 'resume' | 'switch') {
if (controlLoading.value || modelPickerLoading.value) return
modelPickerMode.value = mode
modelPickerLoading.value = true
try {
const models = await api<Array<{ id: string; name: string; modelId: string; enabled: boolean; defaultModel: boolean }>>('/api/models')
selectableModels.value = models.filter(model => model.enabled)
if (!selectableModels.value.length) {
ElMessage.error('没有可用模型,请先启用模型配置')
return
}
selectedModelConfigId.value = selectableModels.value.find(model => model.id === currentModelConfigId.value)?.id
|| selectableModels.value.find(model => model.defaultModel)?.id
|| selectableModels.value[0]!.id
modelPickerVisible.value = true
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '模型列表加载失败')
} finally {
modelPickerLoading.value = false
}
}
/**
* 使用用户明确选择的模型恢复任务,或中断当前 Run 后创建新的 RESUME Run。
* 请求成功后立即更新页面中的 Run 状态和绑定模型,事件流随后会补齐完整审计事件。
*/
async function confirmModelSelection() {
if (!selectedModelConfigId.value || controlLoading.value) return
controlLoading.value = true controlLoading.value = true
try { try {
const run = await api<{ status: string }>(`/api/projects/${projectId.value}/runs/resume`, { method: 'POST' }) const endpoint = modelPickerMode.value === 'switch' ? 'switch-model' : 'resume'
const run = await api<{ status: string; modelConfigId?: string }>(`/api/projects/${projectId.value}/runs/${endpoint}`, {
method: 'POST',
body: JSON.stringify({ modelConfigId: selectedModelConfigId.value })
})
runStatus.value = run.status runStatus.value = run.status
currentModelConfigId.value = run.modelConfigId || selectedModelConfigId.value
modelPickerVisible.value = false
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '继续失败') const fallback = modelPickerMode.value === 'switch' ? '切换失败' : '继续失败'
ElMessage.error(error instanceof Error ? error.message : fallback)
} finally { } finally {
controlLoading.value = false controlLoading.value = false
} }
@@ -368,12 +414,33 @@ onBeforeUnmount(() => {
<header class="page-header"> <header class="page-header">
<div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div> <div class="page-heading"><h1>{{ project.companyName }}</h1><span class="tag blue">{{ levelLabel }}</span><span class="tag" :class="project.status === 'DELIVERED' ? 'green' : ''">{{ statusLabel }}</span></div>
<div class="run-actions"> <div class="run-actions">
<el-button v-if="running" text :loading="modelPickerLoading" @click="openModelPicker('switch')">切换模型</el-button>
<el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button> <el-button v-if="running" text :loading="controlLoading" @click="stopRun">停止</el-button>
<el-button v-else-if="interrupted" type="primary" plain :loading="controlLoading" @click="resumeRun">继续</el-button> <el-button v-else-if="interrupted" type="primary" plain :loading="modelPickerLoading" @click="openModelPicker('resume')">继续</el-button>
<el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button> <el-button text type="danger" :loading="deleting" :disabled="running" @click="deleteProject">删除</el-button>
</div> </div>
</header> </header>
<el-dialog v-model="modelPickerVisible" :title="modelPickerTitle" width="420px" :close-on-click-modal="false">
<label class="model-picker-field">
<span>运行模型</span>
<el-select v-model="selectedModelConfigId" placeholder="选择模型" style="width: 100%">
<el-option
v-for="model in selectableModels"
:key="model.id"
:label="`${model.name} · ${model.modelId}${model.defaultModel ? '(默认)' : ''}`"
:value="model.id"
/>
</el-select>
</label>
<template #footer>
<el-button :disabled="controlLoading" @click="modelPickerVisible = false">取消</el-button>
<el-button type="primary" :loading="controlLoading" :disabled="!selectedModelConfigId" @click="confirmModelSelection">
{{ modelPickerConfirmText }}
</el-button>
</template>
</el-dialog>
<div class="work-scroll"> <div class="work-scroll">
<section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section> <section v-if="historyLoading && !events.length" class="history-loading" aria-live="polite">加载记录</section>
<section v-else-if="!events.length" class="material-start"> <section v-else-if="!events.length" class="material-start">

View File

@@ -153,27 +153,37 @@ a { color: inherit; text-decoration: none; }
.back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; } .back-to-bottom { position: fixed; right: 32px; bottom: 28px; width: 38px; height: 38px; border: 0; border-radius: 50%; background: #fff; color: var(--blue); box-shadow: 0 6px 22px rgba(29, 58, 111, .14); cursor: pointer; }
.back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; } .back-to-bottom:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; } .empty-main { display: grid; place-items: center; min-height: 100vh; color: #8995a8; }
.model-picker-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); align-items: center; gap: 14px; min-height: 52px; }
.model-picker-field > span { color: #53617b; font-size: 14px; }
.settings-page { min-height: 100vh; padding: 28px 42px; } .settings-page { min-height: 100vh; padding: 28px 42px; }
.settings-page > header h1 { margin: 0; font-size: 28px; } .settings-page > header h1 { margin: 0; font-size: 28px; }
.settings-page > header p { color: #5f6d86; margin: 10px 0 0; } .settings-page > header p { color: #5f6d86; margin: 10px 0 0; }
.settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; } .settings-grid { display: grid; grid-template-columns: 380px minmax(520px, 1fr); margin-top: 38px; min-height: 740px; }
.settings-list { padding-right: 28px; border-right: 1px solid var(--line); } .settings-list { padding-right: 28px; border-right: 1px solid var(--line); }
.settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; } .settings-list h2, .skill-list h2 { font-size: 18px; margin: 0 0 18px; }
.settings-list h2 small { margin-left: 6px; color: #7b879b; font-size: 13px; font-weight: 500; }
.settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; } .settings-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; padding: 18px 16px; border: 0; border-radius: 7px; background: #fff; cursor: pointer; }
.settings-list button.selected { background: #edf4ff; color: var(--blue); } .settings-list button.selected { background: #edf4ff; color: var(--blue); }
.settings-list button.disabled strong, .settings-list button.disabled span { color: #8b96a8; }
.settings-list button strong, .settings-list button span { grid-column: 1; } .settings-list button strong, .settings-list button span { grid-column: 1; }
.settings-list button span { margin-top: 8px; color: #5f6d86; } .settings-list button span { margin-top: 8px; color: #5f6d86; }
.settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); } .settings-list button small { grid-column: 2; grid-row: 1 / span 2; align-self: end; color: var(--green); }
.settings-list button small.muted { color: #8b96a8; }
.settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; } .settings-list button i, .skill-list button > i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-right: 6px; }
.settings-list button small.muted i { background: #9ca6b5; }
.model-empty { min-height: 160px; display: grid; place-items: center; color: #8995a8; border: 1px dashed #d9e0ea; border-radius: 7px; }
.settings-form { padding-left: 28px; } .settings-form { padding-left: 28px; }
.form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); } .form-title { height: 62px; display: flex; align-items: start; justify-content: space-between; border-bottom: 1px solid var(--line); }
.form-title h2 { margin: 0; font-size: 22px; } .form-title h2 { margin: 0; font-size: 22px; }
.model-title-actions { display: flex; align-items: center; gap: 10px; }
.settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); } .settings-form > label { display: grid; grid-template-columns: 170px minmax(320px, 1fr); align-items: center; min-height: 88px; border-bottom: 1px solid var(--line); }
.capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); } .capability-row { display: grid; grid-template-columns: 170px 1fr; min-height: 88px; align-items: center; border-bottom: 1px solid var(--line); }
.capability-row div { display: flex; gap: 8px; } .capability-row div { display: flex; gap: 8px; }
.capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; } .capability-row b { padding: 6px 10px; border: 1px solid #d3dbe7; border-radius: 5px; font-size: 13px; font-weight: 500; }
.model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; } .model-actions { display: flex; align-items: center; gap: 16px; padding-top: 28px; }
.model-danger-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
.connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; } .connection-ok { color: var(--green); display: inline-flex; align-items: center; gap: 6px; }
.connection-ok svg { width: 18px; } .connection-ok svg { width: 18px; }
@@ -229,6 +239,7 @@ a { color: inherit; text-decoration: none; }
.work-scroll { width: calc(100% - 32px); } .work-scroll { width: calc(100% - 32px); }
.page-header { padding: 0 16px; } .page-header { padding: 0 16px; }
.page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; } .page-header h1 { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 20px; }
.run-actions { gap: 2px; }
.ask-card, .artifact-card { max-width: 100%; margin-left: 0; } .ask-card, .artifact-card { max-width: 100%; margin-left: 0; }
.flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; } .flow-tool, .flow-reasoning, .flow-notice { padding-left: 28px; }
.flow-tool::before, .flow-reasoning::before { left: 7px; } .flow-tool::before, .flow-reasoning::before { left: 7px; }
@@ -236,10 +247,13 @@ a { color: inherit; text-decoration: none; }
.material-item { grid-template-columns: 1fr 160px; } .material-item { grid-template-columns: 1fr 160px; }
.material-upload { grid-column: 2; } .material-upload { grid-column: 2; }
.settings-page { padding: 24px 16px; } .settings-page { padding: 24px 16px; }
.settings-header { align-items: center; }
.settings-grid, .skill-grid { grid-template-columns: 1fr; } .settings-grid, .skill-grid { grid-template-columns: 1fr; }
.settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; } .settings-list, .skill-list { max-height: 300px; border-right: 0; border-bottom: 1px solid var(--line); padding: 0 0 20px; }
.settings-form, .skill-detail { padding: 24px 0 0; } .settings-form, .skill-detail { padding: 24px 0 0; }
.settings-form > label, .capability-row { grid-template-columns: 130px 1fr; } .settings-form > label, .capability-row { grid-template-columns: 130px 1fr; }
.model-actions { flex-wrap: wrap; }
.model-danger-actions { width: 100%; margin-left: 0; padding-top: 6px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -4,7 +4,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173, port: 15173,
proxy: { proxy: {
'/api': 'http://127.0.0.1:8080' '/api': 'http://127.0.0.1:8080'
} }