feat: 完善多模型配置与运行切换
将模型连接统一持久化管理,并支持 Agent 运行中切换模型以及中断后选择模型继续。补充配置校验、事务与前后端交互测试。
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,4 +14,5 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
test_data/
|
deepseek_key.txt
|
||||||
|
test_data/
|
||||||
|
|||||||
@@ -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 也可在页面内查看或配置。
|
模型连接只能在“模型配置”页面新增并持久化到 PostgreSQL;API Key 会使用 `APP_MASTER_KEY`
|
||||||
|
环境变量提供的主密钥加密后保存。`dashscope_key.txt` 仅供百炼知识库使用,不参与模型配置。
|
||||||
|
|
||||||
|
启动后端前必须设置模型密钥加密主密钥:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:APP_MASTER_KEY = '<使用独立生成的高强度密钥>'
|
||||||
|
```
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
sk-8d1419754bce4306bc99854ee5ccd505
|
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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") // 查询只投影模型 ID,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对外模型视图。
|
* 对外模型视图。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 正文。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
|
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
4
web-ui/public/favicon.svg
Normal file
4
web-ui/public/favicon.svg
Normal 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 |
138
web-ui/src/pages/ModelsPage.test.ts
Normal file
138
web-ui/src/pages/ModelsPage.test.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
139
web-ui/src/pages/ProjectPage.test.ts
Normal file
139
web-ui/src/pages/ProjectPage.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user