chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
83
server/manuagent-agent/pom.xml
Normal file
83
server/manuagent-agent/pom.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
<artifactId>manuagent-agent</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-tx</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>tools.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-harness</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-model-openai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-agui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.agentscope</groupId>
|
||||
<artifactId>agentscope-extensions-skill-postgresql-repository</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,73 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.artifact} 表的最终产物实体。
|
||||
*/
|
||||
@Table(value = "artifact", schema = "app")
|
||||
public class ArtifactEntity {
|
||||
|
||||
/** 产物主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 所属项目。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID projectId;
|
||||
/** 生成该产物的 Agent Run。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID runId;
|
||||
/** 产物业务类型。 */
|
||||
private String kind;
|
||||
/** 下载文件名。 */
|
||||
private String name;
|
||||
/** 相对于项目根目录的受控路径。 */
|
||||
private String relativePath;
|
||||
/** 文件 MIME 类型。 */
|
||||
private String mimeType;
|
||||
/** 文件字节数。 */
|
||||
private Long sizeBytes;
|
||||
/** 文件内容 SHA-256。 */
|
||||
private String sha256;
|
||||
/** 业务元数据 JSON。 */
|
||||
@Column(jdbcType = org.apache.ibatis.type.JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String metadataJson;
|
||||
/** 最近发布时间。 */
|
||||
private OffsetDateTime publishedAt;
|
||||
/** 首次创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getProjectId() { return projectId; }
|
||||
public void setProjectId(UUID projectId) { this.projectId = projectId; }
|
||||
public UUID getRunId() { return runId; }
|
||||
public void setRunId(UUID runId) { this.runId = runId; }
|
||||
public String getKind() { return kind; }
|
||||
public void setKind(String kind) { this.kind = kind; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getRelativePath() { return relativePath; }
|
||||
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
|
||||
public String getMimeType() { return mimeType; }
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
public Long getSizeBytes() { return sizeBytes; }
|
||||
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
|
||||
public String getSha256() { return sha256; }
|
||||
public void setSha256(String sha256) { this.sha256 = sha256; }
|
||||
public String getMetadataJson() { return metadataJson; }
|
||||
public void setMetadataJson(String metadataJson) { this.metadataJson = metadataJson; }
|
||||
public OffsetDateTime getPublishedAt() { return publishedAt; }
|
||||
public void setPublishedAt(OffsetDateTime publishedAt) { this.publishedAt = publishedAt; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
|
||||
|
||||
/**
|
||||
* 提供产物基础查询以及 PostgreSQL 原子 upsert 能力。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ArtifactMapper extends BaseMapper<ArtifactEntity> {
|
||||
|
||||
/**
|
||||
* 按“项目 + 相对路径”插入或更新产物,并返回数据库中的完整记录。
|
||||
*
|
||||
* @param artifact 待发布产物
|
||||
* @return 插入或更新后的产物记录
|
||||
*/
|
||||
ArtifactEntity upsert(@Param("artifact") ArtifactEntity artifact);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 校验、登记和下载 Agent 最终产物。
|
||||
*/
|
||||
@Service
|
||||
public class ArtifactService {
|
||||
|
||||
private final ArtifactMapper artifactMapper;
|
||||
private final ProjectFileService fileService;
|
||||
private final DocxValidator docxValidator;
|
||||
|
||||
/**
|
||||
* 创建产物服务。
|
||||
*
|
||||
* @param artifactMapper 产物 Mapper
|
||||
* @param fileService 项目文件服务
|
||||
* @param docxValidator DOCX 校验器
|
||||
*/
|
||||
public ArtifactService(
|
||||
ArtifactMapper artifactMapper, ProjectFileService fileService, DocxValidator docxValidator) {
|
||||
this.artifactMapper = artifactMapper;
|
||||
this.fileService = fileService;
|
||||
this.docxValidator = docxValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本轮沙箱候选 DOCX,原子复制到正式目录并登记产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param runStartedAt Run 开始时间
|
||||
* @param metadata 业务元数据
|
||||
* @return 已发布产物
|
||||
*/
|
||||
public ArtifactView publishCandidate(
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
Instant runStartedAt,
|
||||
JsonNode metadata) {
|
||||
Path candidates = fileService.safeProjectPath(projectId, "work/candidates");
|
||||
Path candidate = newestDocx(candidates, runStartedAt);
|
||||
DocxValidator.ValidationResult validation = docxValidator.validate(candidate);
|
||||
Path target = fileService.safeProjectPath(projectId, "artifacts/" + candidate.getFileName());
|
||||
Path temporary = target.resolveSibling(target.getFileName() + ".publishing");
|
||||
try {
|
||||
Files.copy(candidate, temporary, StandardCopyOption.REPLACE_EXISTING);
|
||||
docxValidator.validate(temporary);
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException exception) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupException) {
|
||||
exception.addSuppressed(cleanupException);
|
||||
}
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
|
||||
}
|
||||
tools.jackson.databind.node.ObjectNode enriched = metadata.isObject()
|
||||
? ((tools.jackson.databind.node.ObjectNode) metadata).deepCopy()
|
||||
: tools.jackson.databind.node.JsonNodeFactory.instance.objectNode();
|
||||
enriched.put("docxValidated", true);
|
||||
enriched.put("docxEntries", validation.entryCount());
|
||||
enriched.put("commentCount", validation.commentCount());
|
||||
return publish(
|
||||
projectId,
|
||||
runId,
|
||||
"DOCX",
|
||||
target.getFileName().toString(),
|
||||
"artifacts/" + target.getFileName(),
|
||||
enriched);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布工作区中的 DOCX 产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param kind 产物类型
|
||||
* @param name 文件名
|
||||
* @param relativePath 项目相对路径
|
||||
* @param metadata 业务摘要
|
||||
* @return 产物元数据
|
||||
*/
|
||||
public ArtifactView publish(
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
String kind,
|
||||
String name,
|
||||
String relativePath,
|
||||
JsonNode metadata) {
|
||||
Path path = fileService.safeProjectPath(projectId, relativePath);
|
||||
if (!relativePath.startsWith("artifacts/") || !Files.isRegularFile(path)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_INVALID", "产物文件不存在或不在发布目录");
|
||||
}
|
||||
try {
|
||||
long size = Files.size(path);
|
||||
if (size <= 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
|
||||
}
|
||||
String hash = sha256(path);
|
||||
// “项目 + 路径”必须原子 upsert,避免先查后写在并发重试时触发唯一约束竞态。
|
||||
ArtifactEntity entity = new ArtifactEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setProjectId(projectId);
|
||||
entity.setRunId(runId);
|
||||
entity.setKind(kind);
|
||||
entity.setName(name);
|
||||
entity.setRelativePath(relativePath);
|
||||
entity.setMimeType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
entity.setSizeBytes(size);
|
||||
entity.setSha256(hash);
|
||||
entity.setMetadataJson(metadata.toString());
|
||||
ArtifactEntity stored = artifactMapper.upsert(entity);
|
||||
return toArtifactView(stored);
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
private Path newestDocx(Path directory, Instant runStartedAt) {
|
||||
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
|
||||
return paths
|
||||
.filter(path -> path.getFileName().toString().toLowerCase(java.util.Locale.ROOT).endsWith(".docx"))
|
||||
.filter(path -> path.toFile().lastModified() >= runStartedAt.toEpochMilli())
|
||||
.max(java.util.Comparator.comparingLong(path -> path.toFile().lastModified()))
|
||||
.orElseThrow(() -> new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"DOCX_NOT_GENERATED",
|
||||
"Agent 未生成本轮 DOCX"));
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_SCAN_FAILED", "申报书候选目录无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目产物。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 按发布时间倒序的产物
|
||||
*/
|
||||
public List<ArtifactView> list(UUID projectId) {
|
||||
QueryWrapper query = artifactViewQuery()
|
||||
.where(ArtifactEntity::getProjectId).eq(projectId)
|
||||
.orderBy(ArtifactEntity::getPublishedAt).desc();
|
||||
return artifactMapper.selectListByQuery(query).stream()
|
||||
.map(ArtifactService::toArtifactView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取产物下载资源。
|
||||
*
|
||||
* @param artifactId 产物 ID
|
||||
* @return 下载信息
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public Download download(UUID artifactId) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ArtifactEntity::getProjectId,
|
||||
ArtifactEntity::getName,
|
||||
ArtifactEntity::getRelativePath,
|
||||
ArtifactEntity::getMimeType,
|
||||
ArtifactEntity::getSizeBytes,
|
||||
ArtifactEntity::getSha256)
|
||||
.where(ArtifactEntity::getId).eq(artifactId);
|
||||
ArtifactEntity entity = artifactMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在");
|
||||
}
|
||||
StoredArtifact artifact = new StoredArtifact(
|
||||
entity.getProjectId(),
|
||||
entity.getName(),
|
||||
entity.getRelativePath(),
|
||||
entity.getMimeType(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getSha256());
|
||||
try {
|
||||
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
|
||||
Resource resource = new UrlResource(path.toUri());
|
||||
if (!resource.exists()) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_FILE_NOT_FOUND", "产物文件不存在");
|
||||
}
|
||||
if (Files.size(path) != artifact.sizeBytes() || !sha256(path).equals(artifact.sha256())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
|
||||
}
|
||||
return new Download(artifact.name(), artifact.mimeType(), resource);
|
||||
} catch (java.net.MalformedURLException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PATH_INVALID", "产物路径无效");
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件 SHA-256。
|
||||
*
|
||||
* @param path 文件路径
|
||||
* @return 小写十六进制哈希
|
||||
* @throws IOException 文件读取失败时抛出
|
||||
* @throws NoSuchAlgorithmException 运行环境不支持 SHA-256 时抛出
|
||||
*/
|
||||
private String sha256(Path path) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
for (int read; (read = input.read(buffer)) >= 0;) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将产物实体转换成接口稳定视图。
|
||||
*
|
||||
* @param entity 产物实体
|
||||
* @return 产物接口视图
|
||||
*/
|
||||
private static ArtifactView toArtifactView(ArtifactEntity entity) {
|
||||
return new ArtifactView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getRunId(),
|
||||
entity.getKind(),
|
||||
entity.getName(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getMetadataJson(),
|
||||
entity.getPublishedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造产物接口列表使用的最小字段投影。
|
||||
*
|
||||
* <p>该字段集合与迁移前 JDBC 列表 SQL 保持一致。下载路径、MIME 类型和 SHA-256
|
||||
* 仅在下载场景读取,避免普通列表查询加载不参与响应的内部字段。</p>
|
||||
*
|
||||
* @return 只包含产物接口视图字段的查询构造器
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private static QueryWrapper artifactViewQuery() {
|
||||
return QueryWrapper.create().select(
|
||||
ArtifactEntity::getId,
|
||||
ArtifactEntity::getProjectId,
|
||||
ArtifactEntity::getRunId,
|
||||
ArtifactEntity::getKind,
|
||||
ArtifactEntity::getName,
|
||||
ArtifactEntity::getSizeBytes,
|
||||
ArtifactEntity::getMetadataJson,
|
||||
ArtifactEntity::getPublishedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 产物元数据。
|
||||
*
|
||||
* @param id 产物 ID
|
||||
* @param projectId 项目 ID
|
||||
* @param runId 生成 Run
|
||||
* @param kind 类型
|
||||
* @param name 文件名
|
||||
* @param sizeBytes 文件大小
|
||||
* @param metadataJson 业务摘要 JSON
|
||||
* @param publishedAt 发布时间
|
||||
*/
|
||||
public record ArtifactView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
String kind,
|
||||
String name,
|
||||
long sizeBytes,
|
||||
String metadataJson,
|
||||
OffsetDateTime publishedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载结果。
|
||||
*
|
||||
* @param name 下载文件名
|
||||
* @param mimeType MIME 类型
|
||||
* @param resource 文件资源
|
||||
*/
|
||||
public record Download(String name, String mimeType, Resource resource) {
|
||||
}
|
||||
|
||||
private record StoredArtifact(
|
||||
UUID projectId,
|
||||
String name,
|
||||
String relativePath,
|
||||
String mimeType,
|
||||
long sizeBytes,
|
||||
String sha256) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
/**
|
||||
* 使用 JDK 标准库校验 DOCX 容器、核心 OOXML 和原生批注引用。
|
||||
*/
|
||||
@Component
|
||||
public class DocxValidator {
|
||||
|
||||
private static final Set<String> REQUIRED_ENTRIES = Set.of(
|
||||
"[Content_Types].xml", "_rels/.rels", "word/document.xml");
|
||||
private static final int MAX_ENTRIES = 10_000;
|
||||
private static final long MAX_UNCOMPRESSED_BYTES = 512L * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 校验 DOCX 文件可以被 Word 作为完整 OOXML 文档读取。
|
||||
*
|
||||
* @param path DOCX 文件
|
||||
* @return 校验摘要
|
||||
* @throws ApiException 文件损坏、结构缺失或批注引用异常时抛出
|
||||
*/
|
||||
public ValidationResult validate(Path path) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw invalid("DOCX 文件不存在");
|
||||
}
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
Set<String> names = new HashSet<>();
|
||||
long uncompressed = 0;
|
||||
var entries = zip.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
String name = entry.getName();
|
||||
if (name.startsWith("/") || name.contains("../") || !names.add(name)) {
|
||||
throw invalid("DOCX 包含非法或重复路径");
|
||||
}
|
||||
if (names.size() > MAX_ENTRIES) {
|
||||
throw invalid("DOCX 文件数量超过限制");
|
||||
}
|
||||
if (entry.getSize() > 0) {
|
||||
uncompressed = Math.addExact(uncompressed, entry.getSize());
|
||||
if (uncompressed > MAX_UNCOMPRESSED_BYTES) {
|
||||
throw invalid("DOCX 解压后大小超过限制");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!names.containsAll(REQUIRED_ENTRIES)) {
|
||||
throw invalid("DOCX 缺少必要的 Word 文档结构");
|
||||
}
|
||||
|
||||
Document document = parse(zip, "word/document.xml");
|
||||
if (!"document".equals(document.getDocumentElement().getLocalName())
|
||||
|| document.getElementsByTagNameNS("*", "body").getLength() != 1
|
||||
|| document.getElementsByTagNameNS("*", "t").getLength() == 0) {
|
||||
throw invalid("DOCX 正文结构为空或无效");
|
||||
}
|
||||
int comments = validateComments(zip, names, document);
|
||||
return new ValidationResult(Files.size(path), names.size(), comments);
|
||||
} catch (ApiException exception) {
|
||||
throw exception;
|
||||
} catch (ArithmeticException | IOException exception) {
|
||||
throw invalid("DOCX 容器损坏或无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
private int validateComments(ZipFile zip, Set<String> names, Document document) throws IOException {
|
||||
Map<String, Integer> references = idCounts(document, "commentReference");
|
||||
Map<String, Integer> starts = idCounts(document, "commentRangeStart");
|
||||
Map<String, Integer> ends = idCounts(document, "commentRangeEnd");
|
||||
Set<String> referenced = new HashSet<>(references.keySet());
|
||||
referenced.addAll(starts.keySet());
|
||||
referenced.addAll(ends.keySet());
|
||||
if (!names.contains("word/comments.xml")) {
|
||||
if (!referenced.isEmpty()) {
|
||||
throw invalid("DOCX 正文引用了不存在的批注");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Document comments = parse(zip, "word/comments.xml");
|
||||
Set<String> declared = new HashSet<>();
|
||||
NodeList nodes = comments.getElementsByTagNameNS("*", "comment");
|
||||
for (int index = 0; index < nodes.getLength(); index++) {
|
||||
String id = attributeByLocalName((Element) nodes.item(index), "id");
|
||||
if (id.isBlank() || !declared.add(id)) {
|
||||
throw invalid("DOCX 包含重复或无编号批注");
|
||||
}
|
||||
}
|
||||
if (declared.isEmpty() || !declared.equals(referenced)) {
|
||||
throw invalid("DOCX 批注与正文锚点不一致");
|
||||
}
|
||||
for (String id : declared) {
|
||||
if (references.getOrDefault(id, 0) != 1
|
||||
|| starts.getOrDefault(id, 0) != 1
|
||||
|| ends.getOrDefault(id, 0) != 1) {
|
||||
throw invalid("DOCX 批注锚点重复或不完整");
|
||||
}
|
||||
}
|
||||
if (!names.contains("word/_rels/document.xml.rels")) {
|
||||
throw invalid("DOCX 批注缺少关系定义");
|
||||
}
|
||||
Document relationships = parse(zip, "word/_rels/document.xml.rels");
|
||||
boolean linked = false;
|
||||
NodeList relations = relationships.getElementsByTagNameNS("*", "Relationship");
|
||||
for (int index = 0; index < relations.getLength(); index++) {
|
||||
Element relation = (Element) relations.item(index);
|
||||
if ("comments.xml".equals(relation.getAttribute("Target"))) {
|
||||
linked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!linked) {
|
||||
throw invalid("DOCX 批注关系未连接到正文");
|
||||
}
|
||||
return declared.size();
|
||||
}
|
||||
|
||||
private Map<String, Integer> idCounts(Document document, String localName) {
|
||||
Map<String, Integer> values = new HashMap<>();
|
||||
NodeList nodes = document.getElementsByTagNameNS("*", localName);
|
||||
for (int index = 0; index < nodes.getLength(); index++) {
|
||||
String id = attributeByLocalName((Element) nodes.item(index), "id");
|
||||
if (!id.isBlank()) {
|
||||
values.merge(id, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String attributeByLocalName(Element element, String localName) {
|
||||
for (int index = 0; index < element.getAttributes().getLength(); index++) {
|
||||
Node attribute = element.getAttributes().item(index);
|
||||
if (localName.equals(attribute.getLocalName()) || localName.equals(attribute.getNodeName())) {
|
||||
return attribute.getNodeValue();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private Document parse(ZipFile zip, String name) throws IOException {
|
||||
ZipEntry entry = zip.getEntry(name);
|
||||
if (entry == null) {
|
||||
throw invalid("DOCX 缺少必要 XML:" + name);
|
||||
}
|
||||
try (InputStream input = zip.getInputStream(entry)) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
return factory.newDocumentBuilder().parse(input);
|
||||
} catch (ApiException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw invalid("DOCX XML 无法解析:" + name);
|
||||
}
|
||||
}
|
||||
|
||||
private ApiException invalid(String message) {
|
||||
return new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "DOCX_INVALID", message);
|
||||
}
|
||||
|
||||
/**
|
||||
* DOCX 校验摘要。
|
||||
*
|
||||
* @param sizeBytes 文件大小
|
||||
* @param entryCount ZIP 条目数
|
||||
* @param commentCount 原生批注数
|
||||
*/
|
||||
public record ValidationResult(long sizeBytes, int entryCount, int commentCount) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package tech.easyflow.manuagent.agent.config;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 应用自身的运行配置。
|
||||
*
|
||||
* @param dataRoot 项目材料、工作区和产物根目录
|
||||
* @param dashscopeKeyFile 百炼 Key 文件
|
||||
* @param masterKey 模型密钥加密主密钥
|
||||
* @param sandboxImage Agent Docker 运行镜像
|
||||
* @param sandboxNetwork Agent Docker 网络
|
||||
* @param runTimeout 单次 Agent 运行超时
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public record AgentProperties(
|
||||
Path dataRoot,
|
||||
Path dashscopeKeyFile,
|
||||
String masterKey,
|
||||
String sandboxImage,
|
||||
String sandboxNetwork,
|
||||
Duration runTimeout) {
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 使用 AES-GCM 加密数据库中的模型密钥。
|
||||
*/
|
||||
@Component
|
||||
public class KeyCipher {
|
||||
|
||||
private static final int IV_LENGTH = 12;
|
||||
private static final int TAG_LENGTH = 128;
|
||||
|
||||
private final SecretKeySpec key;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 创建密钥加密器。
|
||||
*
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public KeyCipher(AgentProperties properties) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(properties.masterKey().getBytes(StandardCharsets.UTF_8));
|
||||
this.key = new SecretKeySpec(digest, "AES");
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("无法初始化密钥加密器", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密明文密钥。
|
||||
*
|
||||
* @param plaintext 明文
|
||||
* @return IV 与密文组合字节
|
||||
*/
|
||||
public byte[] encrypt(String plaintext) {
|
||||
byte[] iv = new byte[IV_LENGTH];
|
||||
random.nextBytes(iv);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
return ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array();
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_ENCRYPT_FAILED", "模型密钥加密失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据库密钥。
|
||||
*
|
||||
* @param encrypted IV 与密文组合字节
|
||||
* @return 明文密钥
|
||||
*/
|
||||
public String decrypt(byte[] encrypted) {
|
||||
if (encrypted == null || encrypted.length <= IV_LENGTH) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_MISSING", "模型密钥未配置");
|
||||
}
|
||||
byte[] iv = Arrays.copyOfRange(encrypted, 0, IV_LENGTH);
|
||||
byte[] ciphertext = Arrays.copyOfRange(encrypted, IV_LENGTH, encrypted.length);
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_DECRYPT_FAILED", "模型密钥解密失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.model_assignment} 表的 Agent 角色模型分配实体。
|
||||
*/
|
||||
@Table(value = "model_assignment", schema = "app")
|
||||
public class ModelAssignmentEntity {
|
||||
|
||||
/** Agent 角色,同时也是业务主键。 */
|
||||
@Id(keyType = KeyType.None)
|
||||
private String role;
|
||||
/** 分配的模型配置。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID modelConfigId;
|
||||
/** 执行分配的用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID assignedBy;
|
||||
/** 最近更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public String getRole() { return role; }
|
||||
public void setRole(String role) { this.role = role; }
|
||||
public UUID getModelConfigId() { return modelConfigId; }
|
||||
public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; }
|
||||
public UUID getAssignedBy() { return assignedBy; }
|
||||
public void setAssignedBy(UUID assignedBy) { this.assignedBy = assignedBy; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
|
||||
|
||||
/**
|
||||
* 提供 Agent 角色模型分配及 PostgreSQL 原子 upsert。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ModelAssignmentMapper extends BaseMapper<ModelAssignmentEntity> {
|
||||
|
||||
/** 按角色插入或更新模型分配。 */
|
||||
int upsert(@Param("assignment") ModelAssignmentEntity assignment);
|
||||
|
||||
/** 删除指定模型遗留的角色分配。 */
|
||||
int deleteByModelConfigId(@Param("modelConfigId") java.util.UUID modelConfigId);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.model_config} 表的模型配置与加密密钥实体。
|
||||
*
|
||||
* <p>密钥字段始终保存 AES-GCM 密文;实体只在服务内部使用,严禁直接作为接口响应。</p>
|
||||
*/
|
||||
@Table(value = "model_config", schema = "app")
|
||||
public class ModelConfigEntity {
|
||||
|
||||
/** 模型配置主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 配置名称。 */
|
||||
private String name;
|
||||
/** 模型服务商类型。 */
|
||||
private String provider;
|
||||
/** OpenAI 兼容 API 根地址。 */
|
||||
private String baseUrl;
|
||||
/** 上游模型标识。 */
|
||||
private String modelId;
|
||||
/** AES-GCM 加密后的 API Key。 */
|
||||
private byte[] apiKeyCiphertext;
|
||||
/** 仅用于界面展示的密钥尾号。 */
|
||||
private String apiKeyHint;
|
||||
/** 密钥加密格式版本。 */
|
||||
private Short keyVersion;
|
||||
/** 高级请求配置 JSON。 */
|
||||
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String configJson;
|
||||
/** 模型能力 JSON。 */
|
||||
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String capabilitiesJson;
|
||||
/** 是否启用。 */
|
||||
private Boolean enabled;
|
||||
/** 是否为全局默认模型。 */
|
||||
@Column("is_default")
|
||||
private Boolean defaultModel;
|
||||
/** 创建用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID createdBy;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
public String getBaseUrl() { return baseUrl; }
|
||||
public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; }
|
||||
public String getModelId() { return modelId; }
|
||||
public void setModelId(String modelId) { this.modelId = modelId; }
|
||||
public byte[] getApiKeyCiphertext() { return apiKeyCiphertext; }
|
||||
public void setApiKeyCiphertext(byte[] apiKeyCiphertext) { this.apiKeyCiphertext = apiKeyCiphertext; }
|
||||
public String getApiKeyHint() { return apiKeyHint; }
|
||||
public void setApiKeyHint(String apiKeyHint) { this.apiKeyHint = apiKeyHint; }
|
||||
public Short getKeyVersion() { return keyVersion; }
|
||||
public void setKeyVersion(Short keyVersion) { this.keyVersion = keyVersion; }
|
||||
public String getConfigJson() { return configJson; }
|
||||
public void setConfigJson(String configJson) { this.configJson = configJson; }
|
||||
public String getCapabilitiesJson() { return capabilitiesJson; }
|
||||
public void setCapabilitiesJson(String capabilitiesJson) { this.capabilitiesJson = capabilitiesJson; }
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public Boolean getDefaultModel() { return defaultModel; }
|
||||
public void setDefaultModel(Boolean defaultModel) { this.defaultModel = defaultModel; }
|
||||
public UUID getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
|
||||
/**
|
||||
* 提供模型配置的 MyBatis-Flex CRUD 能力。
|
||||
*
|
||||
* <p>普通查询和条件更新继续复用 {@link BaseMapper};包含 PostgreSQL JSONB 参数的新增、更新
|
||||
* 使用 XML 显式声明 TypeHandler,避免写入行为依赖 MyBatis-Flex 全局表元数据的初始化顺序。</p>
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ModelConfigMapper extends BaseMapper<ModelConfigEntity> {
|
||||
|
||||
/**
|
||||
* 新增模型配置,并显式按 JSONB 类型绑定高级配置与能力声明。
|
||||
*
|
||||
* @param model 待新增模型实体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int insertModel(@Param("model") ModelConfigEntity model);
|
||||
|
||||
/**
|
||||
* 更新模型可编辑字段;实体未携带新密钥时保留数据库中的原密钥。
|
||||
*
|
||||
* @param model 待更新模型实体
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateModel(@Param("model") ModelConfigEntity model);
|
||||
|
||||
/**
|
||||
* 清除当前默认模型标记,保持与迁移前 JDBC SQL 相同的更新范围。
|
||||
*
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int clearDefault();
|
||||
|
||||
/**
|
||||
* 将指定模型设为默认模型,并由数据库生成更新时间。
|
||||
*
|
||||
* @param id 模型主键
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int setDefault(@Param("id") java.util.UUID id);
|
||||
|
||||
/** 按主键更新模型启用状态,并刷新数据库更新时间。 */
|
||||
int setEnabled(@Param("id") java.util.UUID id, @Param("enabled") boolean enabled);
|
||||
|
||||
/** 删除不再被 Run 或角色分配引用的模型。 */
|
||||
int deleteModel(@Param("id") java.util.UUID id);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentEntity;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
|
||||
*/
|
||||
@Service
|
||||
public class ModelService {
|
||||
|
||||
private final ModelConfigMapper modelMapper;
|
||||
private final ModelAssignmentMapper assignmentMapper;
|
||||
private final AgentRunMapper runMapper;
|
||||
private final KeyCipher keyCipher;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/**
|
||||
* 创建模型服务。
|
||||
*
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param assignmentMapper 角色模型分配 Mapper
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
@Autowired
|
||||
public ModelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AgentRunMapper runMapper,
|
||||
KeyCipher keyCipher,
|
||||
ObjectMapper objectMapper) {
|
||||
this(
|
||||
modelMapper,
|
||||
assignmentMapper,
|
||||
runMapper,
|
||||
keyCipher,
|
||||
objectMapper,
|
||||
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可替换 HTTP 客户端的模型服务,仅供同包测试隔离外部网络边界。
|
||||
*
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param assignmentMapper 角色模型分配 Mapper
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param keyCipher 密钥加密器
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param httpClient 模型连接使用的 HTTP 客户端
|
||||
*/
|
||||
ModelService(
|
||||
ModelConfigMapper modelMapper,
|
||||
ModelAssignmentMapper assignmentMapper,
|
||||
AgentRunMapper runMapper,
|
||||
KeyCipher keyCipher,
|
||||
ObjectMapper objectMapper,
|
||||
HttpClient httpClient) {
|
||||
this.modelMapper = modelMapper;
|
||||
this.assignmentMapper = assignmentMapper;
|
||||
this.runMapper = runMapper;
|
||||
this.keyCipher = keyCipher;
|
||||
this.objectMapper = objectMapper;
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出模型配置,永不返回明文密钥。
|
||||
*
|
||||
* @return 模型列表
|
||||
*/
|
||||
public List<ModelView> list() {
|
||||
QueryWrapper query = modelViewQuery()
|
||||
.orderBy(ModelConfigEntity::getDefaultModel).desc()
|
||||
.orderBy(ModelConfigEntity::getUpdatedAt).desc();
|
||||
return modelMapper.selectListByQuery(query).stream()
|
||||
.map(ModelService::toModelView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存新增或已有模型配置。
|
||||
*
|
||||
* @param id 可选模型 ID
|
||||
* @param input 模型输入
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 保存后的模型
|
||||
*/
|
||||
@Transactional
|
||||
public ModelView save(UUID id, ModelInput input, UUID userId) {
|
||||
|
||||
contextWindow(input.capabilities());
|
||||
if (id == null) {
|
||||
if (input.apiKey() == null || input.apiKey().isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
|
||||
}
|
||||
QueryWrapper defaultQuery = QueryWrapper.create()
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||
boolean firstDefault = modelMapper.selectCountByQuery(defaultQuery) == 0;
|
||||
id = UUID.randomUUID();
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
model.setProvider("OPENAI_COMPATIBLE");
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||
model.setKeyVersion((short) 1);
|
||||
model.setDefaultModel(firstDefault);
|
||||
model.setCreatedBy(userId);
|
||||
modelMapper.insertModel(model);
|
||||
if (firstDefault) {
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
upsertAssignment(role, id, userId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModelConfigEntity model = editableModel(id, input);
|
||||
if (input.apiKey() != null && !input.apiKey().isBlank()) {
|
||||
model.setApiKeyCiphertext(keyCipher.encrypt(input.apiKey().trim()));
|
||||
model.setApiKeyHint(hint(input.apiKey().trim()));
|
||||
model.setKeyVersion((short) 1);
|
||||
}
|
||||
int updated = modelMapper.updateModel(model);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
}
|
||||
return require(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型设置为所有角色默认模型。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param userId 当前操作者 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void setDefault(UUID id, UUID userId) {
|
||||
ModelView target = require(id);
|
||||
if (!target.enabled()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_DISABLED", "停用模型不能设为默认模型");
|
||||
}
|
||||
|
||||
modelMapper.clearDefault();
|
||||
if (modelMapper.setDefault(id) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
|
||||
upsertAssignment(role, id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新模型启用状态。
|
||||
*
|
||||
* <p>默认模型承担新 Run 的选择职责,不能直接停用;正在执行的 Run 也不能失去模型,
|
||||
* 用户应先在项目页停止并用替代模型恢复,再停用旧模型。</p>
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param enabled 新启用状态
|
||||
* @return 更新后的安全模型视图
|
||||
*/
|
||||
@Transactional
|
||||
public ModelView setEnabled(UUID id, boolean enabled) {
|
||||
ModelView current = require(id);
|
||||
if (!enabled && current.defaultModel()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "请先设置新的默认模型");
|
||||
}
|
||||
if (!enabled && countRuns(id, true) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_IN_USE", "模型正在被运行中的任务使用");
|
||||
}
|
||||
if (modelMapper.setEnabled(id, enabled) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
return require(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真删除从未被历史 Run 使用的非默认模型。
|
||||
*
|
||||
* <p>历史 Run 的模型引用属于审计事实,任何已有引用都会阻止删除;常规下线应使用停用。</p>
|
||||
*
|
||||
* @param id 模型 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(UUID id) {
|
||||
ModelView current = require(id);
|
||||
if (current.defaultModel()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "DEFAULT_MODEL_REQUIRED", "默认模型不能删除");
|
||||
}
|
||||
if (countRuns(id, false) > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_HISTORY_EXISTS", "模型已有任务记录,请改为停用");
|
||||
}
|
||||
assignmentMapper.deleteByModelConfigId(id);
|
||||
if (modelMapper.deleteModel(id) != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用最小 Chat Completion 请求测试连接。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ConnectionResult test(UUID id) {
|
||||
return testConnection(requireRuntimeModel(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用当前表单草稿测试连接,不将任何草稿字段写入数据库。
|
||||
*
|
||||
* <p>编辑已有模型时,空 API Key 表示复用数据库中的加密密钥;如果表单提供了新 Key,
|
||||
* 则仅在本次请求内使用它。新增模型没有数据库身份,必须显式提供 API Key。</p>
|
||||
*
|
||||
* @param input 当前模型连接草稿
|
||||
* @return 测试结果
|
||||
*/
|
||||
public ConnectionResult test(ConnectionTestInput input) {
|
||||
String baseUrl = normalizeBaseUrl(input.baseUrl());
|
||||
String apiKey = input.apiKey() == null ? "" : input.apiKey().trim();
|
||||
if (apiKey.isBlank()) {
|
||||
apiKey = storedApiKey(input.id(), baseUrl);
|
||||
}
|
||||
ModelSecret draft = new ModelSecret(
|
||||
input.id(),
|
||||
baseUrl,
|
||||
input.modelId().trim(),
|
||||
apiKey,
|
||||
0);
|
||||
return testConnection(draft);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 OpenAI 兼容接口发送最小 Chat Completion 请求。
|
||||
*
|
||||
* @param model 已解析出明文密钥的临时连接配置
|
||||
* @return 测试结果
|
||||
*/
|
||||
private ConnectionResult testConnection(ModelSecret model) {
|
||||
String requestJson = json(Map.of(
|
||||
"model", model.modelId(),
|
||||
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
|
||||
"max_tokens", 8,
|
||||
"stream", false));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(model.baseUrl() + "/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Authorization", "Bearer " + model.apiKey())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||
.build();
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
long elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED",
|
||||
"模型连接失败,服务返回 HTTP " + response.statusCode());
|
||||
}
|
||||
return new ConnectionResult(true, elapsed, "连接正常");
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED", "无法连接模型服务");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "MODEL_CONNECTION_INTERRUPTED", "模型连接测试已中断");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前默认模型及明文 Key,仅供模型调用。
|
||||
*
|
||||
* @return 默认模型机密配置
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public ModelSecret defaultModelSecret() {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getModelId,
|
||||
ModelConfigEntity::getApiKeyCiphertext,
|
||||
ModelConfigEntity::getCapabilitiesJson)
|
||||
.where(ModelConfigEntity::getDefaultModel).eq(true)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型");
|
||||
}
|
||||
return toModelSecret(model);
|
||||
}
|
||||
|
||||
private ModelView require(UUID id) {
|
||||
QueryWrapper query = modelViewQuery()
|
||||
.where(ModelConfigEntity::getId).eq(id);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
|
||||
}
|
||||
return toModelView(model);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked") // 机密配置查询只投影固定列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
/**
|
||||
* 按 Run 已绑定的模型 ID读取当前启用配置及明文 Key,仅供模型调用链使用。
|
||||
*
|
||||
* <p>该方法不会读取全局默认模型,因此管理员切换默认模型只会影响之后创建的 Run;
|
||||
* 如果同一配置被编辑,下一次 Agent 连接会自然读取更新后的地址、模型 ID和密钥。</p>
|
||||
*
|
||||
* @param id Run 绑定的模型配置 ID
|
||||
* @return 可直接创建模型客户端的机密配置
|
||||
*/
|
||||
public ModelSecret requireRuntimeModel(UUID id) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getModelId,
|
||||
ModelConfigEntity::getApiKeyCiphertext,
|
||||
ModelConfigEntity::getCapabilitiesJson)
|
||||
.where(ModelConfigEntity::getId).eq(id)
|
||||
.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(query);
|
||||
if (model == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用");
|
||||
}
|
||||
return toModelSecret(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验模型上下文窗口。
|
||||
*
|
||||
* @param capabilities 模型能力配置
|
||||
* @return 上下文 Token 上限
|
||||
*/
|
||||
private int contextWindow(Map<String, Object> capabilities) {
|
||||
Object value = capabilities == null ? null : capabilities.get("contextWindow");
|
||||
if (!(value instanceof Number number) || number.intValue() < 8_192) {
|
||||
throw new ApiException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"MODEL_CONTEXT_WINDOW_INVALID",
|
||||
"上下文窗口不能小于 8192 Token");
|
||||
}
|
||||
return number.intValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据库中的模型能力配置。
|
||||
*
|
||||
* @param json 能力 JSON
|
||||
* @return 能力键值
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> parseCapabilities(String json) {
|
||||
try {
|
||||
return objectMapper.readValue(json, Map.class);
|
||||
} catch (tools.jackson.core.JacksonException exception) {
|
||||
throw new ApiException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"MODEL_CAPABILITIES_INVALID",
|
||||
"模型能力配置无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取已有模型的保存密钥,供“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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造新增、更新共用的非敏感模型字段。
|
||||
*
|
||||
* @param id 模型主键
|
||||
* @param input 接口输入
|
||||
* @return 待持久化实体
|
||||
*/
|
||||
private ModelConfigEntity editableModel(UUID id, ModelInput input) {
|
||||
ModelConfigEntity model = new ModelConfigEntity();
|
||||
model.setId(id);
|
||||
model.setName(input.name().trim());
|
||||
model.setBaseUrl(normalizeBaseUrl(input.baseUrl()));
|
||||
model.setModelId(input.modelId().trim());
|
||||
model.setConfigJson(json(input.config()));
|
||||
model.setCapabilitiesJson(json(input.capabilities()));
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子插入或更新一个 Agent 角色的模型分配。
|
||||
*/
|
||||
private void upsertAssignment(String role, UUID modelId, UUID userId) {
|
||||
ModelAssignmentEntity assignment = new ModelAssignmentEntity();
|
||||
assignment.setRole(role);
|
||||
assignment.setModelConfigId(modelId);
|
||||
assignment.setAssignedBy(userId);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将包含密文的内部实体转换为模型调用所需的最小明文对象。
|
||||
*/
|
||||
private ModelSecret toModelSecret(ModelConfigEntity model) {
|
||||
return new ModelSecret(
|
||||
model.getId(),
|
||||
model.getBaseUrl(),
|
||||
model.getModelId(),
|
||||
keyCipher.decrypt(model.getApiKeyCiphertext()),
|
||||
contextWindow(parseCapabilities(model.getCapabilitiesJson())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型实体转换为永不包含密文或明文 Key 的接口视图。
|
||||
*/
|
||||
private static ModelView toModelView(ModelConfigEntity model) {
|
||||
return new ModelView(
|
||||
model.getId(),
|
||||
model.getName(),
|
||||
model.getProvider(),
|
||||
model.getBaseUrl(),
|
||||
model.getModelId(),
|
||||
model.getApiKeyHint(),
|
||||
model.getConfigJson(),
|
||||
model.getCapabilitiesJson(),
|
||||
Boolean.TRUE.equals(model.getEnabled()),
|
||||
Boolean.TRUE.equals(model.getDefaultModel()),
|
||||
model.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造模型管理页面使用的安全字段投影。
|
||||
*
|
||||
* <p>普通列表、保存结果和默认模型切换只需要展示字段,因此明确排除 API Key 密文、
|
||||
* 密钥版本和创建人等内部字段。只有模型连接和 Agent 创建路径可以读取密文。</p>
|
||||
*
|
||||
* @return 只包含模型接口展示字段的查询构造器
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private static QueryWrapper modelViewQuery() {
|
||||
return QueryWrapper.create().select(
|
||||
ModelConfigEntity::getId,
|
||||
ModelConfigEntity::getName,
|
||||
ModelConfigEntity::getProvider,
|
||||
ModelConfigEntity::getBaseUrl,
|
||||
ModelConfigEntity::getModelId,
|
||||
ModelConfigEntity::getApiKeyHint,
|
||||
ModelConfigEntity::getConfigJson,
|
||||
ModelConfigEntity::getCapabilitiesJson,
|
||||
ModelConfigEntity::getEnabled,
|
||||
ModelConfigEntity::getDefaultModel,
|
||||
ModelConfigEntity::getUpdatedAt);
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
|
||||
} catch (tools.jackson.core.JacksonException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化用户提供的 OpenAI 兼容服务根地址。
|
||||
*
|
||||
* <p>模型地址最终会由服务端 HTTP 客户端主动访问,因此只允许具有明确主机的 HTTP(S) URI。
|
||||
* 用户信息可能泄露凭据,查询参数和片段会在拼接 {@code /chat/completions} 时产生歧义,均直接拒绝。
|
||||
* 动态模型供应商无法使用固定域名白名单,这里至少保证 URI 结构和请求语义稳定。</p>
|
||||
*
|
||||
* @param baseUrl 用户输入的服务根地址
|
||||
* @return 去除末尾斜杠后的规范地址
|
||||
* @throws ApiException 地址结构或协议不符合要求时抛出
|
||||
*/
|
||||
private String normalizeBaseUrl(String baseUrl) {
|
||||
String value = baseUrl.trim();
|
||||
try {
|
||||
URI uri = URI.create(value);
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
boolean httpScheme = "http".equals(scheme) || "https".equals(scheme);
|
||||
boolean stableRequestTarget = uri.getHost() != null
|
||||
&& !uri.getHost().isBlank()
|
||||
&& uri.getUserInfo() == null
|
||||
&& uri.getRawQuery() == null
|
||||
&& uri.getRawFragment() == null;
|
||||
if (!httpScheme || uri.isOpaque() || !stableRequestTarget) {
|
||||
throw invalidBaseUrl();
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalidBaseUrl();
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造不回显原始地址的统一校验异常,避免地址中意外携带的凭据进入日志或接口响应。
|
||||
*
|
||||
* @return 模型地址校验异常
|
||||
*/
|
||||
private ApiException invalidBaseUrl() {
|
||||
return new ApiException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"MODEL_BASE_URL_INVALID",
|
||||
"模型 API 地址必须是有效的 HTTP 或 HTTPS 地址,且不能包含用户信息、查询参数或片段");
|
||||
}
|
||||
|
||||
private static String hint(String key) {
|
||||
return "••••" + key.substring(Math.max(0, key.length() - 4));
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型编辑输入。
|
||||
*
|
||||
* @param name 配置名称
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKey 新密钥;空值表示保留
|
||||
* @param config 高级配置
|
||||
* @param capabilities 能力声明
|
||||
*/
|
||||
public record ModelInput(
|
||||
@NotBlank @Size(max = 100) String name,
|
||||
@NotBlank @Size(max = 500) String baseUrl,
|
||||
@NotBlank @Size(max = 255) String modelId,
|
||||
@Size(max = 4096) String apiKey,
|
||||
Map<String, Object> config,
|
||||
Map<String, Object> capabilities) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不落库的模型连接测试输入。
|
||||
*
|
||||
* @param id 已有模型 ID;新增草稿为 {@code null}
|
||||
* @param baseUrl 当前表单中的 API 地址
|
||||
* @param modelId 当前表单中的模型标识
|
||||
* @param apiKey 当前表单中的新密钥;已有模型且 API 地址未变化时留空可复用保存密钥
|
||||
*/
|
||||
public record ConnectionTestInput(
|
||||
UUID id,
|
||||
@NotBlank @Size(max = 500) String baseUrl,
|
||||
@NotBlank @Size(max = 255) String modelId,
|
||||
@Size(max = 4096) String apiKey) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 对外模型视图。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param name 配置名称
|
||||
* @param provider 服务商
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKeyHint 密钥遮罩
|
||||
* @param configJson 高级配置
|
||||
* @param capabilitiesJson 能力配置
|
||||
* @param enabled 是否启用
|
||||
* @param defaultModel 是否默认
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public record ModelView(
|
||||
UUID id,
|
||||
String name,
|
||||
String provider,
|
||||
String baseUrl,
|
||||
String modelId,
|
||||
String apiKeyHint,
|
||||
String configJson,
|
||||
String capabilitiesJson,
|
||||
boolean enabled,
|
||||
boolean defaultModel,
|
||||
OffsetDateTime updatedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部模型机密配置。
|
||||
*
|
||||
* @param id 模型 ID
|
||||
* @param baseUrl API 地址
|
||||
* @param modelId 模型标识
|
||||
* @param apiKey 明文密钥
|
||||
* @param contextWindow 上下文 Token 上限
|
||||
*/
|
||||
public record ModelSecret(UUID id, String baseUrl, String modelId, String apiKey, int contextWindow) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接测试结果。
|
||||
*
|
||||
* @param success 是否成功
|
||||
* @param latencyMs 往返耗时
|
||||
* @param message 状态说明
|
||||
*/
|
||||
public record ConnectionResult(boolean success, long latencyMs, String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.project} 表的企业申报项目实体。
|
||||
*/
|
||||
@Table(value = "project", schema = "app")
|
||||
public class ProjectEntity {
|
||||
|
||||
/** 项目主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 企业名称。 */
|
||||
private String companyName;
|
||||
/** 项目显示名称。 */
|
||||
private String projectName;
|
||||
/** AgentScope/AG-UI 使用的线程标识。 */
|
||||
private String aguiThreadId;
|
||||
/** 申报等级。 */
|
||||
private String applicationLevel;
|
||||
/** 当前业务阶段。 */
|
||||
private String status;
|
||||
/** 创建用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID createdBy;
|
||||
/** 业务版本号。 */
|
||||
private Long version;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public String getCompanyName() { return companyName; }
|
||||
public void setCompanyName(String companyName) { this.companyName = companyName; }
|
||||
public String getProjectName() { return projectName; }
|
||||
public void setProjectName(String projectName) { this.projectName = projectName; }
|
||||
public String getAguiThreadId() { return aguiThreadId; }
|
||||
public void setAguiThreadId(String aguiThreadId) { this.aguiThreadId = aguiThreadId; }
|
||||
public String getApplicationLevel() { return applicationLevel; }
|
||||
public void setApplicationLevel(String applicationLevel) { this.applicationLevel = applicationLevel; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public UUID getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||
public Long getVersion() { return version; }
|
||||
public void setVersion(Long version) { this.version = version; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.project_file} 表的项目材料实体。
|
||||
*
|
||||
* <p>实体保存文件的受控相对路径、完整性摘要及软删除状态;真实文件仍由
|
||||
* {@code ProjectFileService} 在项目工作区内管理。</p>
|
||||
*/
|
||||
@Table(value = "project_file", schema = "app")
|
||||
public class ProjectFileEntity {
|
||||
|
||||
/** 文件主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 所属项目主键。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID projectId;
|
||||
/** 用户上传时的原始文件名。 */
|
||||
private String originalName;
|
||||
/** 工作区内实际保存的文件名。 */
|
||||
private String storedName;
|
||||
/** 相对于项目根目录的受控路径。 */
|
||||
private String relativePath;
|
||||
/** 内容检测得到的 MIME 类型。 */
|
||||
private String mimeType;
|
||||
/** 小写文件扩展名。 */
|
||||
private String extension;
|
||||
/** 文件字节数。 */
|
||||
private Long sizeBytes;
|
||||
/** 文件内容 SHA-256。 */
|
||||
private String sha256;
|
||||
/** 材料处理状态。 */
|
||||
private String status;
|
||||
/** 上传用户主键。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID uploadedBy;
|
||||
/** 软删除时间,空值表示有效。 */
|
||||
private OffsetDateTime deletedAt;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getProjectId() { return projectId; }
|
||||
public void setProjectId(UUID projectId) { this.projectId = projectId; }
|
||||
public String getOriginalName() { return originalName; }
|
||||
public void setOriginalName(String originalName) { this.originalName = originalName; }
|
||||
public String getStoredName() { return storedName; }
|
||||
public void setStoredName(String storedName) { this.storedName = storedName; }
|
||||
public String getRelativePath() { return relativePath; }
|
||||
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
|
||||
public String getMimeType() { return mimeType; }
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
public String getExtension() { return extension; }
|
||||
public void setExtension(String extension) { this.extension = extension; }
|
||||
public Long getSizeBytes() { return sizeBytes; }
|
||||
public void setSizeBytes(Long sizeBytes) { this.sizeBytes = sizeBytes; }
|
||||
public String getSha256() { return sha256; }
|
||||
public void setSha256(String sha256) { this.sha256 = sha256; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public UUID getUploadedBy() { return uploadedBy; }
|
||||
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
|
||||
public OffsetDateTime getDeletedAt() { return deletedAt; }
|
||||
public void setDeletedAt(OffsetDateTime deletedAt) { this.deletedAt = deletedAt; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
|
||||
|
||||
/**
|
||||
* 提供 {@code app.project_file} 表的单表持久化能力。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ProjectFileMapper extends BaseMapper<ProjectFileEntity> {
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.apache.tika.Tika;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 保存、校验和读取项目企业材料。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectFileService {
|
||||
|
||||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
|
||||
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
|
||||
|
||||
private final ProjectFileMapper fileMapper;
|
||||
private final ProjectService projectService;
|
||||
private final Path dataRoot;
|
||||
private final Tika tika = new Tika();
|
||||
|
||||
/**
|
||||
* 创建材料服务。
|
||||
*
|
||||
* @param fileMapper 项目材料 Mapper
|
||||
* @param projectService 项目服务
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public ProjectFileService(
|
||||
ProjectFileMapper fileMapper,
|
||||
ProjectService projectService,
|
||||
AgentProperties properties) {
|
||||
this.fileMapper = fileMapper;
|
||||
this.projectService = projectService;
|
||||
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化项目工作区目录。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
*/
|
||||
public void ensureWorkspace(UUID projectId) {
|
||||
Path root = projectRoot(projectId);
|
||||
try {
|
||||
for (String directory : List.of(
|
||||
"inputs", "work/facts", "work/plans", "work/drafts", "work/reviews", "work/tmp",
|
||||
"work/candidates", "references", "artifacts", "skills")) {
|
||||
Files.createDirectories(root.resolve(directory));
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_CREATE_FAILED", "项目工作区创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目的整个受控工作区。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @throws ApiException 文件删除失败时抛出
|
||||
*/
|
||||
public void deleteWorkspace(UUID projectId) {
|
||||
Path root = projectRoot(projectId);
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (var paths = Files.walk(root)) {
|
||||
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_DELETE_FAILED", "项目文件删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并校验企业材料。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param file 上传文件
|
||||
* @param relativePath 浏览器提供的文件夹内相对路径;单文件上传时可为空
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 文件元数据
|
||||
*/
|
||||
@Transactional
|
||||
public FileView upload(UUID projectId, MultipartFile file, String relativePath, UUID userId) {
|
||||
projectService.require(projectId);
|
||||
String originalName = safeName(file.getOriginalFilename());
|
||||
String extension = extension(originalName);
|
||||
if (!ALLOWED_EXTENSIONS.contains(extension)) {
|
||||
throw new ApiException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_TYPE_NOT_ALLOWED", "暂不支持该文件类型");
|
||||
}
|
||||
|
||||
ensureWorkspace(projectId);
|
||||
UUID fileId = UUID.randomUUID();
|
||||
String workspacePath = normalizeUploadPath(relativePath, originalName);
|
||||
Path target = safeProjectPath(projectId, workspacePath);
|
||||
Path temporary = target.resolveSibling(target.getFileName() + ".uploading-" + fileId);
|
||||
boolean moved = false;
|
||||
try {
|
||||
Files.createDirectories(target.getParent());
|
||||
if (Files.exists(target)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
|
||||
}
|
||||
String mime;
|
||||
try (InputStream input = file.getInputStream()) {
|
||||
mime = tika.detect(input, originalName);
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (DigestInputStream input = new DigestInputStream(file.getInputStream(), digest)) {
|
||||
Files.copy(input, temporary);
|
||||
}
|
||||
Files.move(temporary, target);
|
||||
moved = true;
|
||||
|
||||
// 数据库只保存受控路径和摘要;selective insert 继续使用状态、时间字段的数据库默认值。
|
||||
ProjectFileEntity entity = new ProjectFileEntity();
|
||||
entity.setId(fileId);
|
||||
entity.setProjectId(projectId);
|
||||
entity.setOriginalName(originalName);
|
||||
entity.setStoredName(target.getFileName().toString());
|
||||
entity.setRelativePath(workspacePath);
|
||||
entity.setMimeType(mime);
|
||||
entity.setExtension(extension);
|
||||
entity.setSizeBytes(Files.size(target));
|
||||
entity.setSha256(HexFormat.of().formatHex(digest.digest()));
|
||||
entity.setUploadedBy(userId);
|
||||
fileMapper.insertSelective(entity);
|
||||
return require(fileId);
|
||||
} catch (FileAlreadyExistsException exception) {
|
||||
cleanupFailedUpload(exception, temporary);
|
||||
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_STORE_FAILED", "文件保存失败");
|
||||
} catch (RuntimeException exception) {
|
||||
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未完成上传留下的临时文件或孤儿正式文件。
|
||||
*
|
||||
* @param failure 原始异常
|
||||
* @param paths 待清理路径
|
||||
*/
|
||||
private void cleanupFailedUpload(Throwable failure, Path... paths) {
|
||||
for (Path path : paths) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException cleanupException) {
|
||||
failure.addSuppressed(cleanupException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目有效材料。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 文件元数据列表
|
||||
*/
|
||||
public List<FileView> list(UUID projectId) {
|
||||
projectService.require(projectId);
|
||||
QueryWrapper query = fileViewQuery()
|
||||
.where(ProjectFileEntity::getProjectId).eq(projectId)
|
||||
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||
.orderBy(ProjectFileEntity::getRelativePath).asc();
|
||||
return fileMapper.selectListByQuery(query).stream()
|
||||
.map(ProjectFileService::toFileView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材料下载资源。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param fileId 文件 ID
|
||||
* @return 文件资源
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public Download download(UUID projectId, UUID fileId) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
ProjectFileEntity::getOriginalName,
|
||||
ProjectFileEntity::getRelativePath,
|
||||
ProjectFileEntity::getMimeType)
|
||||
.where(ProjectFileEntity::getId).eq(fileId)
|
||||
.and(ProjectFileEntity::getProjectId).eq(projectId)
|
||||
.and(ProjectFileEntity::getDeletedAt).isNull()
|
||||
.and(ProjectFileEntity::getStatus).eq("READY");
|
||||
ProjectFileEntity entity = fileMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||
}
|
||||
StoredFile stored = new StoredFile(entity.getOriginalName(), entity.getRelativePath(), entity.getMimeType());
|
||||
try {
|
||||
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
|
||||
if (!resource.exists()) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件内容不存在");
|
||||
}
|
||||
return new Download(stored.originalName(), stored.mimeType(), resource);
|
||||
} catch (java.net.MalformedURLException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_PATH_INVALID", "文件路径无效");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 document_view 生成的项目内预览图。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param relativePath 工作区相对路径
|
||||
* @return 预览图资源
|
||||
* @throws ApiException 路径非法或图片不存在时抛出
|
||||
*/
|
||||
public Preview preview(UUID projectId, String relativePath) {
|
||||
projectService.require(projectId);
|
||||
String normalized = relativePath == null ? "" : relativePath.replace('\\', '/');
|
||||
if (!normalized.startsWith("work/tmp/document-view/")
|
||||
|| !(normalized.endsWith(".png") || normalized.endsWith(".jpg") || normalized.endsWith(".jpeg"))) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "PREVIEW_PATH_INVALID", "预览图路径无效");
|
||||
}
|
||||
Path path = safeProjectPath(projectId, normalized);
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PREVIEW_NOT_FOUND", "预览图不存在");
|
||||
}
|
||||
try {
|
||||
String mimeType = Files.probeContentType(path);
|
||||
return new Preview(mimeType == null ? "image/png" : mimeType, new UrlResource(path.toUri()));
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "PREVIEW_READ_FAILED", "预览图读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回规范化项目根目录。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 项目根目录
|
||||
*/
|
||||
public Path projectRoot(UUID projectId) {
|
||||
return dataRoot.resolve("projects").resolve(projectId.toString()).normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 在项目根目录内解析相对路径。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param relativePath 相对路径
|
||||
* @return 安全绝对路径
|
||||
*/
|
||||
public Path safeProjectPath(UUID projectId, String relativePath) {
|
||||
Path root = projectRoot(projectId);
|
||||
Path result = root.resolve(relativePath).normalize();
|
||||
if (!result.startsWith(root)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
|
||||
}
|
||||
verifyExistingParent(root, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝通过现有符号链接把后续路径解析到项目目录之外。
|
||||
*
|
||||
* @param root 项目根目录
|
||||
* @param result 待使用路径
|
||||
*/
|
||||
private void verifyExistingParent(Path root, Path result) {
|
||||
try {
|
||||
Path existing = result;
|
||||
while (existing != null && !Files.exists(existing)) {
|
||||
existing = existing.getParent();
|
||||
}
|
||||
if (existing != null && !existing.toRealPath().startsWith(root.toRealPath())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件路径无效");
|
||||
}
|
||||
}
|
||||
|
||||
private FileView require(UUID fileId) {
|
||||
ProjectFileEntity entity = fileMapper.selectOneByQuery(
|
||||
fileViewQuery().where(ProjectFileEntity::getId).eq(fileId));
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在");
|
||||
}
|
||||
return toFileView(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造项目材料接口视图使用的最小字段投影。
|
||||
*
|
||||
* <p>该投影与迁移前 JDBC 列表和单条查询的显式字段保持一致,仅排除存储文件名、
|
||||
* 文件摘要、上传人和更新时间等当前接口不需要的内部列。查询条件和业务判断仍由
|
||||
* 调用方追加,因此本方法只承担 ORM 查询字段收敛,不改变任何业务语义。</p>
|
||||
*
|
||||
* @return 只包含文件接口视图字段的查询构造器
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private static QueryWrapper fileViewQuery() {
|
||||
return QueryWrapper.create().select(
|
||||
ProjectFileEntity::getId,
|
||||
ProjectFileEntity::getProjectId,
|
||||
ProjectFileEntity::getOriginalName,
|
||||
ProjectFileEntity::getRelativePath,
|
||||
ProjectFileEntity::getMimeType,
|
||||
ProjectFileEntity::getExtension,
|
||||
ProjectFileEntity::getSizeBytes,
|
||||
ProjectFileEntity::getStatus,
|
||||
ProjectFileEntity::getCreatedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化实体转换为稳定的接口视图,避免把数据库字段直接暴露给控制器。
|
||||
*
|
||||
* @param entity 项目材料实体
|
||||
* @return 文件接口视图
|
||||
*/
|
||||
private static FileView toFileView(ProjectFileEntity entity) {
|
||||
return new FileView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getOriginalName(),
|
||||
entity.getRelativePath(),
|
||||
entity.getMimeType(),
|
||||
entity.getExtension(),
|
||||
entity.getSizeBytes() == null ? 0L : entity.getSizeBytes(),
|
||||
entity.getStatus(),
|
||||
entity.getCreatedAt());
|
||||
}
|
||||
|
||||
private String safeName(String originalName) {
|
||||
if (originalName == null || originalName.isBlank()) {
|
||||
return "未命名文件";
|
||||
}
|
||||
return Path.of(originalName).getFileName().toString().replaceAll("[\\r\\n]", "_");
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化上传路径并保留用户选择的文件夹层级。
|
||||
*
|
||||
* @param suppliedPath 浏览器提供的文件夹内路径
|
||||
* @param originalName 原始文件名
|
||||
* @return 相对于项目根目录的 inputs 路径
|
||||
* @throws ApiException 路径越界、过长或文件名不一致时抛出
|
||||
*/
|
||||
String normalizeUploadPath(String suppliedPath, String originalName) {
|
||||
String candidate = suppliedPath == null || suppliedPath.isBlank()
|
||||
? originalName
|
||||
: suppliedPath.replace('\\', '/');
|
||||
if (candidate.startsWith("/") || candidate.indexOf('\0') >= 0) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
|
||||
}
|
||||
Path path;
|
||||
try {
|
||||
path = Path.of(candidate).normalize();
|
||||
} catch (RuntimeException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
|
||||
}
|
||||
if (path.isAbsolute() || path.getNameCount() == 0 || path.startsWith("..")
|
||||
|| !path.getFileName().toString().equals(originalName)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
|
||||
}
|
||||
for (Path segment : path) {
|
||||
String value = segment.toString();
|
||||
if (value.isBlank() || ".".equals(value) || "..".equals(value)
|
||||
|| value.getBytes(StandardCharsets.UTF_8).length > 255) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
|
||||
}
|
||||
}
|
||||
String result = "inputs/" + path.toString().replace('\\', '/');
|
||||
if (result.length() > 1_000 || originalName.length() > 500) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_TOO_LONG", "文件夹路径过长");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String extension(String name) {
|
||||
int dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业材料元数据。
|
||||
*
|
||||
* @param id 文件 ID
|
||||
* @param projectId 项目 ID
|
||||
* @param name 原始文件名
|
||||
* @param relativePath 工作区相对路径
|
||||
* @param mimeType 检测到的 MIME
|
||||
* @param extension 扩展名
|
||||
* @param sizeBytes 文件大小
|
||||
* @param status 文件状态
|
||||
* @param createdAt 上传时间
|
||||
*/
|
||||
public record FileView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
String name,
|
||||
String relativePath,
|
||||
String mimeType,
|
||||
String extension,
|
||||
long sizeBytes,
|
||||
String status,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载结果。
|
||||
*
|
||||
* @param name 下载文件名
|
||||
* @param mimeType MIME 类型
|
||||
* @param resource 文件资源
|
||||
*/
|
||||
public record Download(String name, String mimeType, Resource resource) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文档视觉预览结果。
|
||||
*
|
||||
* @param mimeType 图片 MIME 类型
|
||||
* @param resource 图片资源
|
||||
*/
|
||||
public record Preview(String mimeType, Resource resource) {
|
||||
}
|
||||
|
||||
private record StoredFile(String originalName, String relativePath, String mimeType) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectEntity;
|
||||
|
||||
/**
|
||||
* 提供项目基础 CRUD、阶段更新和项目级联清理所需的显式 SQL 接口。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ProjectMapper extends BaseMapper<ProjectEntity> {
|
||||
|
||||
/** 判断项目是否仍有运行中的 Agent。 */
|
||||
boolean hasRunningRun(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 删除项目事件。 */
|
||||
int deleteEvents(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 删除项目产物。 */
|
||||
int deleteArtifacts(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 删除项目规划。 */
|
||||
int deletePlans(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 删除项目材料元数据。 */
|
||||
int deleteFiles(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 删除项目运行记录。 */
|
||||
int deleteRuns(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 原子更新项目阶段并递增版本。 */
|
||||
int updateStatus(@Param("projectId") UUID projectId, @Param("status") String status);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.project_plan} 表的不可变规划版本实体。
|
||||
*/
|
||||
@Table(value = "project_plan", schema = "app")
|
||||
public class ProjectPlanEntity {
|
||||
|
||||
/** 规划主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 所属项目。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID projectId;
|
||||
/** 项目内递增版本号。 */
|
||||
private Integer planVersion;
|
||||
/** 草稿、已确认或已取代状态。 */
|
||||
private String status;
|
||||
/** 完整规划 JSON。 */
|
||||
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String planJson;
|
||||
/** 创建用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID createdBy;
|
||||
/** 确认用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID confirmedBy;
|
||||
/** 确认时间。 */
|
||||
private OffsetDateTime confirmedAt;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getProjectId() { return projectId; }
|
||||
public void setProjectId(UUID projectId) { this.projectId = projectId; }
|
||||
public Integer getPlanVersion() { return planVersion; }
|
||||
public void setPlanVersion(Integer planVersion) { this.planVersion = planVersion; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public String getPlanJson() { return planJson; }
|
||||
public void setPlanJson(String planJson) { this.planJson = planJson; }
|
||||
public UUID getCreatedBy() { return createdBy; }
|
||||
public void setCreatedBy(UUID createdBy) { this.createdBy = createdBy; }
|
||||
public UUID getConfirmedBy() { return confirmedBy; }
|
||||
public void setConfirmedBy(UUID confirmedBy) { this.confirmedBy = confirmedBy; }
|
||||
public OffsetDateTime getConfirmedAt() { return confirmedAt; }
|
||||
public void setConfirmedAt(OffsetDateTime confirmedAt) { this.confirmedAt = confirmedAt; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
|
||||
|
||||
/**
|
||||
* 提供规划版本查询以及带条件的草稿写入、确认能力。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface ProjectPlanMapper extends BaseMapper<ProjectPlanEntity> {
|
||||
|
||||
/** 插入项目下一版草稿并返回完整记录。 */
|
||||
ProjectPlanEntity insertNextDraft(@Param("plan") ProjectPlanEntity plan);
|
||||
|
||||
/** 按“已确认优先、版本倒序”读取项目当前规划。 */
|
||||
ProjectPlanEntity selectCurrent(@Param("projectId") UUID projectId);
|
||||
|
||||
/** 仅将仍处于 DRAFT 的指定版本确认,并返回确认后的记录。 */
|
||||
ProjectPlanEntity confirmDraft(
|
||||
@Param("projectId") UUID projectId,
|
||||
@Param("planId") UUID planId,
|
||||
@Param("planJson") String planJson,
|
||||
@Param("userId") UUID userId);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 管理企业项目和不可变规划版本。
|
||||
*/
|
||||
@Service
|
||||
public class ProjectService {
|
||||
|
||||
private final ProjectMapper projectMapper;
|
||||
private final ProjectPlanMapper planMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建项目服务。
|
||||
*
|
||||
* @param projectMapper 项目 Mapper
|
||||
* @param planMapper 规划版本 Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public ProjectService(
|
||||
ProjectMapper projectMapper,
|
||||
ProjectPlanMapper planMapper,
|
||||
ObjectMapper objectMapper) {
|
||||
this.projectMapper = projectMapper;
|
||||
this.planMapper = planMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建企业申报项目。
|
||||
*
|
||||
* @param companyName 企业名称
|
||||
* @param applicationLevel 申报等级
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 新项目
|
||||
*/
|
||||
@Transactional
|
||||
public ProjectView create(String companyName, String applicationLevel, UUID userId) {
|
||||
String level = normalizeLevel(applicationLevel);
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
String threadId = "project-" + id;
|
||||
ProjectEntity entity = new ProjectEntity();
|
||||
entity.setId(id);
|
||||
entity.setCompanyName(companyName.trim());
|
||||
entity.setProjectName(companyName.trim());
|
||||
entity.setAguiThreadId(threadId);
|
||||
entity.setApplicationLevel(level);
|
||||
entity.setCreatedBy(userId);
|
||||
projectMapper.insertSelective(entity);
|
||||
return require(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出最近项目。
|
||||
*
|
||||
* @return 按更新时间倒序的项目
|
||||
*/
|
||||
public List<ProjectView> list() {
|
||||
QueryWrapper query = projectViewQuery().orderBy(ProjectEntity::getUpdatedAt).desc();
|
||||
return projectMapper.selectListByQuery(query).stream()
|
||||
.map(ProjectService::toProjectView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取一个项目。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 项目详情
|
||||
* @throws ApiException 项目不存在时抛出
|
||||
*/
|
||||
public ProjectView require(UUID projectId) {
|
||||
ProjectEntity entity = projectMapper.selectOneByQuery(
|
||||
projectViewQuery().where(ProjectEntity::getId).eq(projectId));
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
return toProjectView(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 真删除项目及其全部业务记录。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @throws ApiException 项目不存在或仍有 Agent 正在执行时抛出
|
||||
*/
|
||||
@Transactional
|
||||
public void delete(UUID projectId) {
|
||||
require(projectId);
|
||||
if (projectMapper.hasRunningRun(projectId)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
|
||||
}
|
||||
|
||||
// 按外键依赖顺序删除,所有语句均受当前 Spring 事务保护。
|
||||
projectMapper.deleteEvents(projectId);
|
||||
projectMapper.deleteArtifacts(projectId);
|
||||
projectMapper.deletePlans(projectId);
|
||||
projectMapper.deleteFiles(projectId);
|
||||
projectMapper.deleteRuns(projectId);
|
||||
int deleted = projectMapper.deleteById(projectId);
|
||||
if (deleted != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目业务阶段。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param status 新阶段
|
||||
*/
|
||||
public void updateStatus(UUID projectId, String status) {
|
||||
int updated = projectMapper.updateStatus(projectId, status);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 生成的规划草稿。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param plan 规划 JSON
|
||||
* @param userId 创建人
|
||||
* @return 规划版本
|
||||
*/
|
||||
@Transactional
|
||||
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
|
||||
ProjectPlanEntity draft = new ProjectPlanEntity();
|
||||
draft.setId(UUID.randomUUID());
|
||||
draft.setProjectId(projectId);
|
||||
draft.setPlanJson(plan.toString());
|
||||
draft.setCreatedBy(userId);
|
||||
ProjectPlanEntity stored = planMapper.insertNextDraft(draft);
|
||||
updateStatus(projectId, "PLANNING");
|
||||
return toPlanView(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回项目当前规划。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 最新规划;不存在时返回空
|
||||
*/
|
||||
public PlanView currentPlan(UUID projectId) {
|
||||
ProjectPlanEntity entity = planMapper.selectCurrent(projectId);
|
||||
return entity == null ? null : toPlanView(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认规划并冻结其内容。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param planId 草稿规划 ID
|
||||
* @param plan 用户确认后的完整规划
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 已确认规划
|
||||
*/
|
||||
@Transactional
|
||||
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, UUID userId) {
|
||||
|
||||
ProjectPlanEntity confirmed = planMapper.confirmDraft(projectId, planId, plan.toString(), userId);
|
||||
if (confirmed == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
|
||||
}
|
||||
updateStatus(projectId, "WRITING");
|
||||
return toPlanView(confirmed);
|
||||
}
|
||||
|
||||
/** 将规划实体解析成包含 JsonNode 的接口视图。 */
|
||||
private PlanView toPlanView(ProjectPlanEntity entity) {
|
||||
try {
|
||||
return new PlanView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getPlanVersion() == null ? 0 : entity.getPlanVersion(),
|
||||
entity.getStatus(),
|
||||
objectMapper.readTree(entity.getPlanJson()),
|
||||
entity.getConfirmedAt(),
|
||||
entity.getCreatedAt());
|
||||
} catch (JacksonException exception) {
|
||||
// 迁移前 ResultSet 映射会将损坏的存量 JSON 作为未预期数据库读取异常处理,不新增业务错误码。
|
||||
throw new IllegalStateException("规划 JSON 无法解析", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将项目实体转换为接口视图。 */
|
||||
private static ProjectView toProjectView(ProjectEntity entity) {
|
||||
return new ProjectView(
|
||||
entity.getId(),
|
||||
entity.getCompanyName(),
|
||||
entity.getProjectName(),
|
||||
entity.getAguiThreadId(),
|
||||
entity.getApplicationLevel(),
|
||||
entity.getStatus(),
|
||||
entity.getVersion() == null ? 0L : entity.getVersion(),
|
||||
entity.getCreatedAt(),
|
||||
entity.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造项目接口列表与详情共用的最小字段投影。
|
||||
*
|
||||
* <p>字段集合与迁移前 JDBC 查询保持一致,创建人只参与写入和审计,不属于当前项目接口响应,
|
||||
* 因而不在普通列表和单条读取时加载。调用方继续负责追加排序或主键条件。</p>
|
||||
*
|
||||
* @return 只包含项目接口视图字段的查询构造器
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private static QueryWrapper projectViewQuery() {
|
||||
return QueryWrapper.create().select(
|
||||
ProjectEntity::getId,
|
||||
ProjectEntity::getCompanyName,
|
||||
ProjectEntity::getProjectName,
|
||||
ProjectEntity::getAguiThreadId,
|
||||
ProjectEntity::getApplicationLevel,
|
||||
ProjectEntity::getStatus,
|
||||
ProjectEntity::getVersion,
|
||||
ProjectEntity::getCreatedAt,
|
||||
ProjectEntity::getUpdatedAt);
|
||||
}
|
||||
|
||||
private String normalizeLevel(String level) {
|
||||
String value = level == null ? "ADVANCED" : level.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!value.equals("ADVANCED") && !value.equals("EXCELLENT")) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_APPLICATION_LEVEL", "申报等级无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目视图。
|
||||
*
|
||||
* @param id 项目 ID
|
||||
* @param companyName 企业名称
|
||||
* @param projectName 项目名称
|
||||
* @param threadId AG-UI 线程 ID
|
||||
* @param applicationLevel 申报等级
|
||||
* @param status 当前状态
|
||||
* @param version 乐观锁版本
|
||||
* @param createdAt 创建时间
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public record ProjectView(
|
||||
UUID id,
|
||||
String companyName,
|
||||
String projectName,
|
||||
String threadId,
|
||||
String applicationLevel,
|
||||
String status,
|
||||
long version,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规划版本视图。
|
||||
*
|
||||
* @param id 规划 ID
|
||||
* @param projectId 项目 ID
|
||||
* @param version 版本号
|
||||
* @param status 规划状态
|
||||
* @param plan 规划 JSON
|
||||
* @param confirmedAt 确认时间
|
||||
* @param createdAt 创建时间
|
||||
*/
|
||||
public record PlanView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
int version,
|
||||
String status,
|
||||
JsonNode plan,
|
||||
OffsetDateTime confirmedAt,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.agent_event} 表的持久化 Agent 事件。
|
||||
*
|
||||
* <p>事件 ID 由 PostgreSQL 的 BIGSERIAL 序列生成,业务代码通过
|
||||
* {@code INSERT ... RETURNING} 原子取得该 ID,确保游标回放顺序与数据库提交顺序一致。</p>
|
||||
*/
|
||||
@Table(value = "agent_event", schema = "app")
|
||||
public class AgentEventEntity {
|
||||
|
||||
/** PostgreSQL 全局递增事件序号。 */
|
||||
@Id(keyType = KeyType.Auto)
|
||||
private Long id;
|
||||
/** 事件所属项目。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID projectId;
|
||||
/** 事件所属 Agent Run。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID runId;
|
||||
/** AG-UI 事件类型。 */
|
||||
private String eventType;
|
||||
/** 用于外部追踪和去重的随机事件标识。 */
|
||||
private String eventId;
|
||||
/** JSONB 格式的事件负载。 */
|
||||
@Column(value = "payload", jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String payloadJson;
|
||||
/** 数据库记录的事件创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public UUID getProjectId() { return projectId; }
|
||||
public void setProjectId(UUID projectId) { this.projectId = projectId; }
|
||||
public UUID getRunId() { return runId; }
|
||||
public void setRunId(UUID runId) { this.runId = runId; }
|
||||
public String getEventType() { return eventType; }
|
||||
public void setEventType(String eventType) { this.eventType = eventType; }
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String eventId) { this.eventId = eventId; }
|
||||
public String getPayloadJson() { return payloadJson; }
|
||||
public void setPayloadJson(String payloadJson) { this.payloadJson = payloadJson; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
|
||||
|
||||
/**
|
||||
* 提供 Agent 事件写入、游标回放及运行恢复所需的持久化能力。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface AgentEventMapper extends BaseMapper<AgentEventEntity> {
|
||||
|
||||
/**
|
||||
* 插入事件并原子返回数据库生成的 BIGSERIAL 序号及创建时间。
|
||||
*
|
||||
* @param event 待写入事件
|
||||
* @return 已持久化的完整事件
|
||||
*/
|
||||
AgentEventEntity insertReturning(@Param("event") AgentEventEntity event);
|
||||
|
||||
/** 查询指定 Run 最近一次 RUN_STARTED 事件中的业务阶段。 */
|
||||
String selectLatestStartedPhase(@Param("runId") UUID runId);
|
||||
|
||||
/** 查询项目最近一次包含材料决策数组的 ASK_RESPONDED 事件负载。 */
|
||||
String selectLatestMaterialResponseJson(@Param("projectId") UUID projectId);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
|
||||
/**
|
||||
* 持久化并查询项目级 AG-UI 事件。
|
||||
*/
|
||||
@Service
|
||||
public class AgentEventService {
|
||||
|
||||
private final AgentEventMapper eventMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建事件服务。
|
||||
*
|
||||
* @param eventMapper Agent 事件 Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public AgentEventService(AgentEventMapper eventMapper, ObjectMapper objectMapper) {
|
||||
this.eventMapper = eventMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 先持久化一个事件,再将其返回给流式接口。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param eventType AG-UI 事件类型
|
||||
* @param payload 事件负载
|
||||
* @return 已分配全局序号的事件
|
||||
*/
|
||||
public EventView append(UUID projectId, UUID runId, String eventType, Object payload) {
|
||||
JsonNode value = objectMapper.valueToTree(payload);
|
||||
ObjectNode object = value.isObject()
|
||||
? (ObjectNode) value
|
||||
: objectMapper.createObjectNode().set("value", value);
|
||||
AgentEventEntity entity = new AgentEventEntity();
|
||||
entity.setProjectId(projectId);
|
||||
entity.setRunId(runId);
|
||||
entity.setEventType(eventType);
|
||||
entity.setEventId(UUID.randomUUID().toString());
|
||||
entity.setPayloadJson(object.toString());
|
||||
// 写入与 RETURNING 必须由同一条 SQL 完成,以原子取得数据库分配的事件游标。
|
||||
EventView event = toEventView(eventMapper.insertReturning(entity));
|
||||
publishAfterCommit(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按游标查询增量事件。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param afterId 排除的最后事件序号
|
||||
* @param limit 最大返回数量
|
||||
* @return 有序事件
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
AgentEventEntity::getId,
|
||||
AgentEventEntity::getProjectId,
|
||||
AgentEventEntity::getRunId,
|
||||
AgentEventEntity::getEventType,
|
||||
AgentEventEntity::getPayloadJson,
|
||||
AgentEventEntity::getCreatedAt)
|
||||
.where(AgentEventEntity::getProjectId).eq(projectId)
|
||||
.and(AgentEventEntity::getId).gt(Math.max(0, afterId))
|
||||
.orderBy(AgentEventEntity::getId).asc()
|
||||
.limit(Math.clamp(limit, 1, 1000));
|
||||
return eventMapper.selectListByQuery(query)
|
||||
.stream()
|
||||
.map(this::toEventView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 先回放数据库增量,再持续推送当前进程产生的新事件。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param afterId 排除的最后事件序号
|
||||
* @return 带轻量心跳的事件流
|
||||
*/
|
||||
public Flux<EventView> streamAfter(UUID projectId, long afterId) {
|
||||
return Flux.defer(() -> {
|
||||
AtomicLong cursor = new AtomicLong(Math.max(0, afterId));
|
||||
Sinks.Many<EventView> sink = liveStreams.computeIfAbsent(
|
||||
projectId, ignored -> Sinks.many().replay().limit(2_048));
|
||||
Mono<List<EventView>> first = queryBatch(projectId, cursor.get());
|
||||
Flux<EventView> backlog = first
|
||||
.expand(batch -> batch.size() == 1_000
|
||||
? queryBatch(projectId, batch.getLast().id())
|
||||
: Mono.empty())
|
||||
.flatMapIterable(batch -> batch);
|
||||
Flux<EventView> events = Flux.concat(backlog, sink.asFlux())
|
||||
.filter(event -> event.id() > cursor.get())
|
||||
.doOnNext(event -> cursor.set(event.id()));
|
||||
Flux<EventView> heartbeat = Flux.interval(java.time.Duration.ofSeconds(15))
|
||||
.map(tick -> new EventView(
|
||||
0,
|
||||
projectId,
|
||||
null,
|
||||
"HEARTBEAT",
|
||||
objectMapper.createObjectNode(),
|
||||
OffsetDateTime.now()));
|
||||
return Flux.merge(events, heartbeat)
|
||||
.doFinally(signal -> {
|
||||
if (sink.currentSubscriberCount() == 0) {
|
||||
liveStreams.remove(projectId, sink);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<List<EventView>> queryBatch(UUID projectId, long afterId) {
|
||||
return Mono.fromCallable(() -> listAfter(projectId, afterId, 1_000))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private void publishAfterCommit(EventView event) {
|
||||
Runnable publish = () -> {
|
||||
Sinks.Many<EventView> sink = liveStreams.get(event.projectId());
|
||||
if (sink != null) {
|
||||
Sinks.EmitResult result = sink.tryEmitNext(event);
|
||||
if (result == Sinks.EmitResult.FAIL_NON_SERIALIZED) {
|
||||
sink.emitNext(event, Sinks.EmitFailureHandler.busyLooping(java.time.Duration.ofMillis(100)));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
publish.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
publish.run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化实体转换成对外事件视图,并在边界处解析 JSONB 文本。
|
||||
*
|
||||
* @param entity 数据库事件实体
|
||||
* @return 可供 REST 与事件流输出的事件
|
||||
*/
|
||||
private EventView toEventView(AgentEventEntity entity) {
|
||||
try {
|
||||
return new EventView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getRunId(),
|
||||
entity.getEventType(),
|
||||
objectMapper.readTree(entity.getPayloadJson()),
|
||||
entity.getCreatedAt());
|
||||
} catch (tools.jackson.core.JacksonException exception) {
|
||||
throw new IllegalStateException("Agent 事件 JSON 无法解析", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端可恢复事件视图。
|
||||
*
|
||||
* @param id 项目全局事件序号
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID;心跳为空
|
||||
* @param type 事件类型
|
||||
* @param payload AG-UI 事件负载
|
||||
* @param createdAt 产生时间
|
||||
*/
|
||||
public record EventView(
|
||||
long id,
|
||||
UUID projectId,
|
||||
UUID runId,
|
||||
String type,
|
||||
JsonNode payload,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillService;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ArrayNode;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import io.agentscope.core.agui.event.AguiEvent;
|
||||
import io.agentscope.core.agui.model.AguiMessage;
|
||||
import io.agentscope.core.agui.model.RunAgentInput;
|
||||
import io.agentscope.core.model.ModelHttpException;
|
||||
import io.agentscope.core.model.transport.HttpTransportException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 运行 Harness Agent,并把 AG-UI 增量转换为可恢复事件。
|
||||
*/
|
||||
@Service
|
||||
public class AgentExecutionService {
|
||||
|
||||
private static final Pattern HOST_PATH = Pattern.compile("/Users/[^\\s\"')]+");
|
||||
private static final Pattern API_KEY = Pattern.compile("sk-[A-Za-z0-9_-]{8,}");
|
||||
static final int MAX_MODEL_RECONNECTS = 5;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentFactory agentFactory;
|
||||
private final AgentEventService eventService;
|
||||
private final ProjectFileService fileService;
|
||||
private final SkillService skillService;
|
||||
private final Duration runTimeout;
|
||||
|
||||
/**
|
||||
* 创建 Agent 执行服务。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param agentFactory Agent 工厂
|
||||
* @param eventService 事件服务
|
||||
* @param fileService 工作区服务
|
||||
* @param skillService Skill 服务
|
||||
* @param properties Run 总时限配置
|
||||
*/
|
||||
public AgentExecutionService(
|
||||
ObjectMapper objectMapper,
|
||||
AgentFactory agentFactory,
|
||||
AgentEventService eventService,
|
||||
ProjectFileService fileService,
|
||||
SkillService skillService,
|
||||
AgentProperties properties) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.agentFactory = agentFactory;
|
||||
this.eventService = eventService;
|
||||
this.fileService = fileService;
|
||||
this.skillService = skillService;
|
||||
this.runTimeout = properties.runTimeout();
|
||||
if (runTimeout == null || runTimeout.isZero() || runTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("app.run-timeout 必须大于 0");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一个可停止、可重连的 Agent 流。
|
||||
*
|
||||
* @param project 当前项目
|
||||
* @param run 当前 Run
|
||||
* @param prompt 本轮提示词
|
||||
* @param stopSignal 用户停止信号
|
||||
* @param ensureRunning 终态检查
|
||||
* @param interrupted 中断状态查询
|
||||
*/
|
||||
public void execute(
|
||||
ProjectService.ProjectView project,
|
||||
AgentRunService.RunView run,
|
||||
String prompt,
|
||||
Mono<Void> stopSignal,
|
||||
Runnable ensureRunning,
|
||||
BooleanSupplier interrupted) {
|
||||
EventAccumulator accumulator = new EventAccumulator(project.id(), run.id());
|
||||
for (int reconnects = 0; ; reconnects++) {
|
||||
ensureRunning.run();
|
||||
remainingTime(run);
|
||||
String attemptPrompt = reconnects == 0 ? prompt : """
|
||||
模型连接刚刚中断。请恢复同一线程的会话状态,读取 MEMORY.md 和工作区已有成果,
|
||||
检查未完成的输出后从中断处继续;复用已经完成的工具结果,不要重复已完成操作。
|
||||
""";
|
||||
RunAgentInput input = RunAgentInput.builder()
|
||||
.threadId(project.threadId())
|
||||
.runId(run.id().toString())
|
||||
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
|
||||
.build();
|
||||
try (AgentFactory.AgentHandle handle = agentFactory.create(
|
||||
project.id(), run.modelConfigId(), skillService.enabledNames())) {
|
||||
Mono<Void> deadline = Mono.delay(remainingTime(run)).then(Mono.error(timeoutFailure()));
|
||||
handle.adapter().run(input)
|
||||
.takeUntilOther(Mono.firstWithSignal(stopSignal, deadline))
|
||||
.bufferTimeout(64, Duration.ofMillis(120))
|
||||
.doOnNext(batch -> accumulator.accept(batch, handle.runtime()))
|
||||
.blockLast();
|
||||
accumulator.flush();
|
||||
ensureRunning.run();
|
||||
remainingTime(run);
|
||||
return;
|
||||
} catch (RuntimeException exception) {
|
||||
accumulator.flush();
|
||||
if (interrupted.getAsBoolean()) {
|
||||
throw new RunInterruptedException();
|
||||
}
|
||||
if (reconnects >= MAX_MODEL_RECONNECTS || !isRetryableModelFailure(exception)) {
|
||||
throw exception;
|
||||
}
|
||||
int attempt = reconnects + 1;
|
||||
eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of(
|
||||
"attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS));
|
||||
pauseBeforeReconnect(attempt, interrupted, remainingTime(run));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断异常链是否属于可重试的模型连接故障。
|
||||
*
|
||||
* @param failure 模型调用异常
|
||||
* @return 是否允许重连
|
||||
*/
|
||||
static boolean isRetryableModelFailure(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current != null) {
|
||||
if (current instanceof HttpTransportException transport && transport.isRetryable()) {
|
||||
return true;
|
||||
}
|
||||
if (current instanceof ModelHttpException http && http.isRetryableHttpStatus()) {
|
||||
return true;
|
||||
}
|
||||
if (current.getCause() == current) {
|
||||
break;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Duration remainingTime(AgentRunService.RunView run) {
|
||||
Duration remaining = Duration.between(Instant.now(), run.startedAt().toInstant().plus(runTimeout));
|
||||
if (remaining.isNegative() || remaining.isZero()) throw timeoutFailure();
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private ApiException timeoutFailure() {
|
||||
return new ApiException(HttpStatus.REQUEST_TIMEOUT, "AGENT_RUN_TIMEOUT", "任务已超过运行时限,现有成果已保留,可稍后继续");
|
||||
}
|
||||
|
||||
private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted, Duration remaining) {
|
||||
try {
|
||||
Thread.sleep(Math.min(remaining.toMillis(), Math.min(8_000L, 500L << (attempt - 1))));
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (interrupted.getAsBoolean()) {
|
||||
throw new RunInterruptedException();
|
||||
}
|
||||
throw new IllegalStateException("模型重连等待被中断", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标识正常的用户中断。
|
||||
*/
|
||||
static final class RunInterruptedException extends RuntimeException {
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨 Reactor 批次合并连续 token,并过滤 Adapter 自带的重复生命周期事件。
|
||||
*/
|
||||
private final class EventAccumulator {
|
||||
|
||||
private final UUID projectId;
|
||||
private final UUID runId;
|
||||
private ObjectNode pending;
|
||||
private String pendingType;
|
||||
private String pendingKey;
|
||||
private String pendingField;
|
||||
private long lastFlushNanos = System.nanoTime();
|
||||
|
||||
private EventAccumulator(UUID projectId, UUID runId) {
|
||||
this.projectId = projectId;
|
||||
this.runId = runId;
|
||||
}
|
||||
|
||||
private void accept(List<AguiEvent> batch, AgentRuntimeMiddleware runtime) {
|
||||
for (AguiEvent event : batch) {
|
||||
accept(event, runtime);
|
||||
}
|
||||
if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
|
||||
private void accept(AguiEvent event, AgentRuntimeMiddleware runtime) {
|
||||
String type = event.getType().name();
|
||||
// Adapter 将基础设施异常转成事件;必须终止执行,不能当作缺少 JSON 再次调用 Agent。
|
||||
if (event instanceof AguiEvent.RunError error) {
|
||||
String message = sanitize(objectMapper.getNodeFactory().textNode(error.message()),
|
||||
fileService.projectRoot(projectId).toString()).asText();
|
||||
throw new IllegalStateException("Agent 运行错误:" + message, runtime == null ? null : runtime.modelFailure());
|
||||
}
|
||||
if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED")) {
|
||||
return;
|
||||
}
|
||||
ObjectNode payload = (ObjectNode) sanitize(
|
||||
objectMapper.valueToTree(event), fileService.projectRoot(projectId).toString());
|
||||
if (type.equals("TOOL_CALL_RESULT") && payload.path("content").isTextual()) {
|
||||
payload.put("content", stripInlineImageData(payload.path("content").asText()));
|
||||
}
|
||||
String field = switch (type) {
|
||||
case "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK",
|
||||
"REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_CHUNK" ->
|
||||
payload.has("delta") ? "delta" : "content";
|
||||
case "TOOL_CALL_ARGS", "TOOL_CALL_CHUNK" -> payload.has("delta") ? "delta" : "args";
|
||||
default -> null;
|
||||
};
|
||||
String key = payload.path("messageId").asText(payload.path("toolCallId").asText(""));
|
||||
if (field != null && pending != null && type.equals(pendingType)
|
||||
&& key.equals(pendingKey) && field.equals(pendingField)) {
|
||||
pending.put(field, pending.path(field).asText() + payload.path(field).asText());
|
||||
return;
|
||||
}
|
||||
flush();
|
||||
if (field == null) {
|
||||
eventService.append(projectId, runId, type, payload);
|
||||
} else {
|
||||
pending = payload;
|
||||
pendingType = type;
|
||||
pendingKey = key;
|
||||
pendingField = field;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode sanitize(JsonNode node, String workspaceRoot) {
|
||||
if (node.isTextual()) {
|
||||
String text = node.textValue().replace(workspaceRoot, "工作区");
|
||||
text = HOST_PATH.matcher(text).replaceAll("内部路径");
|
||||
return objectMapper.getNodeFactory().textNode(API_KEY.matcher(text).replaceAll("已隐藏凭证"));
|
||||
}
|
||||
if (node instanceof ObjectNode object) {
|
||||
object.properties().forEach(entry -> object.set(
|
||||
entry.getKey(), sanitize(entry.getValue(), workspaceRoot)));
|
||||
} else if (node instanceof ArrayNode array) {
|
||||
for (int index = 0; index < array.size(); index++) {
|
||||
array.set(index, sanitize(array.get(index), workspaceRoot));
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private void flush() {
|
||||
if (pending != null) {
|
||||
eventService.append(projectId, runId, pendingType, pending);
|
||||
pending = null;
|
||||
pendingType = null;
|
||||
pendingKey = null;
|
||||
pendingField = null;
|
||||
}
|
||||
lastFlushNanos = System.nanoTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从持久化 AG-UI 工具结果中移除已经传给模型的 Base64 图片,保留结构化文本元数据。
|
||||
*
|
||||
* @param content AG-UI 工具结果文本
|
||||
* @return 适合数据库与浏览器恢复的精简结果
|
||||
*/
|
||||
static String stripInlineImageData(String content) {
|
||||
int imageStart = content.indexOf("\n{\"type\":\"image\"");
|
||||
if (content.startsWith("document_view_result=") && imageStart > 0) {
|
||||
return content.substring(0, imageStart);
|
||||
}
|
||||
return content.lines()
|
||||
.filter(line -> !((line.contains("\"mediaType\"") || line.contains("\"media_type\""))
|
||||
&& line.contains("\"data\"")))
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.model.ModelService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
|
||||
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import io.agentscope.core.skill.repository.AgentSkillRepository;
|
||||
import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import io.agentscope.core.state.JsonFileAgentStateStore;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
|
||||
import io.agentscope.extensions.model.openai.OpenAIChatModel;
|
||||
import io.agentscope.harness.agent.IsolationScope;
|
||||
import io.agentscope.harness.agent.HarnessAgent;
|
||||
import io.agentscope.harness.agent.memory.compaction.CompactionConfig;
|
||||
import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig;
|
||||
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerFilesystemSpec;
|
||||
import io.agentscope.harness.agent.sandbox.layout.BindMountEntry;
|
||||
import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry;
|
||||
import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec;
|
||||
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 按当前模型、Skill 和项目工作区创建短生命周期 Harness Agent。
|
||||
*/
|
||||
@Component
|
||||
public class AgentFactory {
|
||||
|
||||
private final ModelService modelService;
|
||||
private final PostgresSkillRepository skillRepository;
|
||||
private final ProjectFileService fileService;
|
||||
private final AgentProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String systemPrompt;
|
||||
private final String compactionPrompt;
|
||||
|
||||
/**
|
||||
* 创建 Agent 工厂。
|
||||
*
|
||||
* @param modelService 模型配置服务
|
||||
* @param skillRepository AgentScope Skill 仓库
|
||||
* @param fileService 项目工作区服务
|
||||
* @param properties 应用配置
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public AgentFactory(
|
||||
ModelService modelService,
|
||||
PostgresSkillRepository skillRepository,
|
||||
ProjectFileService fileService,
|
||||
AgentProperties properties,
|
||||
ObjectMapper objectMapper) {
|
||||
this.modelService = modelService;
|
||||
this.skillRepository = skillRepository;
|
||||
this.fileService = fileService;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词");
|
||||
this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词");
|
||||
AgentStateFiles.migrateExisting(properties.dataRoot());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId Run 创建时绑定的模型配置 ID
|
||||
* @param enabledSkills 当前启用 Skill
|
||||
* @return 需要在流结束后关闭的 Agent 句柄
|
||||
*/
|
||||
public AgentHandle create(UUID projectId, UUID modelConfigId, String[] enabledSkills) {
|
||||
// 每次重新建立 Agent 连接时按 Run 固定的模型 ID读取最新配置;全局默认模型只参与新 Run 的选择。
|
||||
ModelService.ModelSecret model = modelService.requireRuntimeModel(modelConfigId);
|
||||
OpenAIChatModel chatModel = OpenAIChatModel.builder()
|
||||
.apiKey(model.apiKey())
|
||||
.baseUrl(model.baseUrl())
|
||||
.modelName(model.modelId())
|
||||
.stream(true)
|
||||
.build();
|
||||
fileService.ensureWorkspace(projectId);
|
||||
Path projectRoot = fileService.projectRoot(projectId);
|
||||
Map<String, String> environment = new HashMap<>();
|
||||
environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile()));
|
||||
environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin");
|
||||
environment.put("NODE_PATH", "/opt/sandbox/node_modules");
|
||||
environment.put("SKILL_SESSION_ID", "project-" + projectId);
|
||||
|
||||
WorkspaceSpec workspace = new WorkspaceSpec();
|
||||
Map<String, WorkspaceEntry> entries = new LinkedHashMap<>();
|
||||
entries.put("inputs", mount(projectRoot.resolve("inputs"), true));
|
||||
entries.put("work", mount(projectRoot.resolve("work"), false));
|
||||
entries.put("references", mount(projectRoot.resolve("references"), false));
|
||||
entries.put("artifacts", mount(projectRoot.resolve("work/candidates"), false));
|
||||
workspace.setEntries(entries);
|
||||
workspace.setEnvironment(environment);
|
||||
|
||||
Path snapshotRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("sandbox-snapshots");
|
||||
try {
|
||||
Files.createDirectories(snapshotRoot);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception);
|
||||
}
|
||||
DockerFilesystemSpec filesystem = new DockerFilesystemSpec()
|
||||
.client(new RecoverableDockerClient(properties.sandboxImage()))
|
||||
.image(properties.sandboxImage())
|
||||
.workspaceRoot("/workspace")
|
||||
.environment(environment)
|
||||
.memorySizeBytes(2L * 1024 * 1024 * 1024)
|
||||
.cpuCount(2L)
|
||||
.exposedPorts()
|
||||
.network(properties.sandboxNetwork())
|
||||
.additionalRunArgs(
|
||||
"--pids-limit=256",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--stop-signal=SIGKILL")
|
||||
.snapshotSpec(new LocalSnapshotSpec(snapshotRoot))
|
||||
.workspaceSpec(workspace);
|
||||
filesystem.isolationScope(IsolationScope.SESSION);
|
||||
|
||||
CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt);
|
||||
Toolkit toolkit = new Toolkit();
|
||||
DocumentViewTool documentView = new DocumentViewTool(objectMapper, fileService, projectId);
|
||||
toolkit.registerTool(documentView);
|
||||
AgentRuntimeMiddleware runtime = new AgentRuntimeMiddleware();
|
||||
Path stateRoot = AgentStateFiles.prepare(properties.dataRoot(), AgentStateFiles.legacyRoot(), projectId);
|
||||
HarnessAgent agent = HarnessAgent.builder()
|
||||
.name("smart-factory-agent")
|
||||
.description("智能工厂申报书规划、编写与评审 Agent")
|
||||
.sysPrompt(systemPrompt)
|
||||
.model(chatModel)
|
||||
.stateStore(new JsonFileAgentStateStore(stateRoot))
|
||||
.middleware(runtime)
|
||||
.toolkit(toolkit)
|
||||
.workspace(projectRoot)
|
||||
.filesystem(filesystem)
|
||||
.skillRepository(new EnabledSkillRepository(skillRepository, Set.copyOf(Arrays.asList(enabledSkills))))
|
||||
.compaction(compaction)
|
||||
.toolResultEviction(ToolResultEvictionConfig.defaults())
|
||||
.maxContextTokens(model.contextWindow())
|
||||
.maxIters(96)
|
||||
// 中断的工具调用以失败结果补齐,由模型检查已有成果后继续;不绕过权限确认。
|
||||
.enablePendingToolRecovery(true)
|
||||
.enableAgentTracingLog(false)
|
||||
.disableSubagents()
|
||||
.build();
|
||||
agent.getToolkit().registerTool(new PagedReadFileTool(
|
||||
agent.getWorkspaceManager().getFilesystem(),
|
||||
WorkspacePathNormalizer.of("/workspace"),
|
||||
objectMapper));
|
||||
agent.getToolkit().registerTool(new SandboxTools(
|
||||
(AbstractSandboxFilesystem) agent.getWorkspaceManager().getFilesystem(), objectMapper));
|
||||
documentView.bind(agent);
|
||||
AguiAdapterConfig config = AguiAdapterConfig.builder()
|
||||
.enableReasoning(true)
|
||||
.emitToolCallArgs(true)
|
||||
.emitStateEvents(false)
|
||||
.defaultAgentId("smart-factory-agent")
|
||||
.build();
|
||||
return new AgentHandle(new AguiAgentAdapter(agent, config), agent, runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模型窗口构造仅由 Token 触发的压缩配置。
|
||||
*
|
||||
* @param contextWindow 模型上下文窗口
|
||||
* @param summaryPrompt 压缩摘要约束
|
||||
* @return AgentScope 压缩配置
|
||||
*/
|
||||
static CompactionConfig compactionFor(int contextWindow, String summaryPrompt) {
|
||||
int triggerTokens = Math.max(1, (int) Math.floor(contextWindow * 0.90d));
|
||||
int keepTokensMin = Math.max(1_024, Math.min(4_000, contextWindow / 8));
|
||||
int keepTokensMax = Math.max(keepTokensMin, Math.min(16_000, contextWindow / 5));
|
||||
return CompactionConfig.builder()
|
||||
.triggerMessages(0)
|
||||
.triggerTokens(triggerTokens)
|
||||
.reserved(Math.max(1_024, contextWindow / 10))
|
||||
.keepTokensMin(keepTokensMin)
|
||||
.keepTokensMax(keepTokensMax)
|
||||
.keepTokensRatio(0.18d)
|
||||
.summaryPrompt(summaryPrompt)
|
||||
.flushBeforeCompact(true)
|
||||
.offloadBeforeCompact(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 AgentScope 默认的 name_source Skill ID 规范化为业务名称,同时保留完整 Skill 信息。
|
||||
*
|
||||
* @param skill 仓库返回的 Skill
|
||||
* @return 使用业务名称作为调用 ID 的 Skill
|
||||
*/
|
||||
static AgentSkill canonicalSkill(AgentSkill skill) {
|
||||
return new AgentSkill(
|
||||
skill.getMetadata(),
|
||||
skill.getSkillContent(),
|
||||
skill.getResources(),
|
||||
skill.getSource(),
|
||||
skill.getOriginDir().orElse(null)) {
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public String getSkillId() {
|
||||
return getName();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private BindMountEntry mount(Path hostPath, boolean readOnly) {
|
||||
BindMountEntry entry = new BindMountEntry();
|
||||
entry.setHostPath(hostPath.toAbsolutePath().normalize().toString());
|
||||
entry.setReadOnly(readOnly);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private String readPrompt(String path, String label) {
|
||||
try {
|
||||
return new ClassPathResource(path)
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法读取" + label, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String readOptionalKey(java.nio.file.Path path) {
|
||||
try {
|
||||
return Files.isRegularFile(path) ? Files.readString(path, StandardCharsets.UTF_8).trim() : "";
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法读取百炼知识库 Key", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定 AG-UI 适配器与其拥有的沙箱 Agent 生命周期。
|
||||
*
|
||||
* @param adapter AG-UI 适配器
|
||||
* @param agent Harness Agent
|
||||
* @param runtime 模型调用约束及原始异常
|
||||
*/
|
||||
public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent, AgentRuntimeMiddleware runtime) implements AutoCloseable {
|
||||
|
||||
/**
|
||||
* 结束 Agent 并释放 Docker 沙箱资源。
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
agent.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只向 Agent 暴露管理员启用的 Skill,所有写操作继续委托原仓库。
|
||||
*/
|
||||
private static final class EnabledSkillRepository implements AgentSkillRepository {
|
||||
|
||||
private final AgentSkillRepository delegate;
|
||||
private final Set<String> enabled;
|
||||
|
||||
private EnabledSkillRepository(AgentSkillRepository delegate, Set<String> enabled) {
|
||||
this.delegate = delegate;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentSkill getSkill(String name) {
|
||||
AgentSkill skill = enabled.contains(name) ? delegate.getSkill(name) : null;
|
||||
return skill == null ? null : canonicalSkill(skill);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getAllSkillNames() {
|
||||
return delegate.getAllSkillNames().stream().filter(enabled::contains).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AgentSkill> getAllSkills() {
|
||||
return delegate.getAllSkills().stream()
|
||||
.filter(skill -> enabled.contains(skill.getName()))
|
||||
.map(AgentFactory::canonicalSkill)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean save(List<AgentSkill> skills, boolean overwrite) {
|
||||
return delegate.save(skills, overwrite);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String name) {
|
||||
return delegate.delete(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean skillExists(String name) {
|
||||
return enabled.contains(name) && delegate.skillExists(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentSkillRepositoryInfo getRepositoryInfo() {
|
||||
return delegate.getRepositoryInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getSource() {
|
||||
return delegate.getSource();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteable(boolean writeable) {
|
||||
delegate.setWriteable(writeable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWriteable() {
|
||||
return delegate.isWriteable();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.IntSummaryStatistics;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 读取并校验 Agent 交给业务流程的结构化文件。
|
||||
*/
|
||||
@Service
|
||||
public class AgentOutputService {
|
||||
|
||||
private static final Pattern YEAR_RANGE = Pattern.compile("(20\\d{2})\\D+(20\\d{2})");
|
||||
private static final Pattern NUMBER = Pattern.compile("(\\d+)");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ProjectFileService fileService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 输出服务。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param fileService 工作区服务
|
||||
*/
|
||||
public AgentOutputService(ObjectMapper objectMapper, ProjectFileService fileService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.fileService = fileService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验材料检验结果。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 材料检验对象
|
||||
* @throws IOException 文件无法读取时抛出
|
||||
*/
|
||||
public ObjectNode readMaterialCheck(UUID projectId) throws IOException {
|
||||
Path path = fileService.safeProjectPath(projectId, "work/facts/material-check.json");
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MATERIAL_CHECK_MISSING",
|
||||
"Agent 未生成材料检验结果,请重试");
|
||||
}
|
||||
JsonNode value = objectMapper.readTree(path.toFile());
|
||||
if (!(value instanceof ObjectNode report)
|
||||
|| !hasText(report, "summary")
|
||||
|| !report.path("completeness").canConvertToInt()
|
||||
|| !report.path("confirmedFacts").isArray()
|
||||
|| !report.path("missingItems").isArray()) {
|
||||
throw new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"MATERIAL_CHECK_INVALID",
|
||||
"Agent 生成的材料检验结果结构不完整,请重试");
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并校验 Agent 建议规划。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 建设规划对象
|
||||
* @throws IOException 文件无法读取时抛出
|
||||
*/
|
||||
public ObjectNode readProposedPlan(UUID projectId) throws IOException {
|
||||
Path path = fileService.safeProjectPath(projectId, "work/plans/proposed-plan.json");
|
||||
if (!Files.isRegularFile(path)) {
|
||||
throw new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"PLAN_OUTPUT_MISSING",
|
||||
"Agent 未生成结构化建设规划,请重试材料检验");
|
||||
}
|
||||
JsonNode value = objectMapper.readTree(path.toFile());
|
||||
if (!(value instanceof ObjectNode plan)) {
|
||||
throw invalidPlan();
|
||||
}
|
||||
normalizePlanningYears(plan);
|
||||
if (!hasText(plan, "coreDirection")
|
||||
|| !hasText(plan, "collaborationDirection")
|
||||
|| !hasText(plan, "factoryName")
|
||||
|| !plan.path("planningYears").canConvertToInt()
|
||||
|| !hasText(plan, "investmentRange")
|
||||
|| !hasText(plan, "applicationLevel")
|
||||
|| !plan.path("scenarios").isArray()
|
||||
|| plan.path("scenarios").isEmpty()
|
||||
|| !plan.path("aiScenarioCount").canConvertToInt()
|
||||
|| !plan.path("assumptions").isArray()) {
|
||||
throw invalidPlan();
|
||||
}
|
||||
plan.put("scenarioCount", plan.path("scenarios").size());
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户确认后的规划满足编写最小结构。
|
||||
*
|
||||
* @param plan 用户确认规划
|
||||
*/
|
||||
public void validateConfirmedPlan(JsonNode plan) {
|
||||
if (plan == null || !plan.isObject()
|
||||
|| !hasText(plan, "coreDirection")
|
||||
|| !hasText(plan, "collaborationDirection")
|
||||
|| !hasText(plan, "factoryName")
|
||||
|| !plan.path("planningYears").canConvertToInt()
|
||||
|| plan.path("planningYears").asInt() < 1
|
||||
|| !hasText(plan, "investmentRange")
|
||||
|| !plan.path("scenarioCount").canConvertToInt()
|
||||
|| plan.path("scenarioCount").asInt() < 1
|
||||
|| !plan.path("aiScenarioCount").canConvertToInt()
|
||||
|| plan.path("aiScenarioCount").asInt() < 0
|
||||
|| plan.path("aiScenarioCount").asInt() > plan.path("scenarioCount").asInt()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "PLAN_INVALID", "请完整填写并检查建设规划");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将模型可能输出的年份数组或文本区间归一化为规划年数。
|
||||
*
|
||||
* @param plan 待归一化的规划对象
|
||||
*/
|
||||
static void normalizePlanningYears(ObjectNode plan) {
|
||||
JsonNode value = plan.path("planningYears");
|
||||
if (value.canConvertToInt()) {
|
||||
return;
|
||||
}
|
||||
if (value.isArray()) {
|
||||
IntSummaryStatistics years = new IntSummaryStatistics();
|
||||
value.forEach(year -> {
|
||||
Matcher matcher = NUMBER.matcher(year.asText());
|
||||
if (matcher.find()) {
|
||||
years.accept(Integer.parseInt(matcher.group(1)));
|
||||
}
|
||||
});
|
||||
if (years.getCount() > 0) {
|
||||
plan.put("planningPeriod", years.getMin() == years.getMax()
|
||||
? Integer.toString(years.getMin())
|
||||
: years.getMin() + "-" + years.getMax());
|
||||
plan.put("planningYears", years.getMax() - years.getMin() + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String text = value.asText();
|
||||
Matcher range = YEAR_RANGE.matcher(text);
|
||||
if (range.find()) {
|
||||
int years = Integer.parseInt(range.group(2)) - Integer.parseInt(range.group(1)) + 1;
|
||||
plan.put("planningPeriod", text);
|
||||
plan.put("planningYears", Math.max(1, years));
|
||||
return;
|
||||
}
|
||||
Matcher number = NUMBER.matcher(text);
|
||||
if (number.find()) {
|
||||
plan.put("planningPeriod", text);
|
||||
plan.put("planningYears", Math.max(1, Integer.parseInt(number.group(1))));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasText(JsonNode value, String field) {
|
||||
return value.path(field).isTextual() && !value.path(field).asText().isBlank();
|
||||
}
|
||||
|
||||
private ApiException invalidPlan() {
|
||||
return new ApiException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"PLAN_OUTPUT_INVALID",
|
||||
"Agent 生成的建设规划结构不完整,请重试材料检验");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.type.JdbcType;
|
||||
import tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.agent_run} 表的 Agent 运行状态实体。
|
||||
*
|
||||
* <p>运行终态切换并不依赖 BaseMapper 的无条件更新,而由 Mapper XML 使用
|
||||
* {@code WHERE status = 'RUNNING'} 实现乐观状态机,避免停止、完成和失败并发覆盖。</p>
|
||||
*/
|
||||
@Table(value = "agent_run", schema = "app")
|
||||
public class AgentRunEntity {
|
||||
|
||||
/** Run 主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
/** 所属项目。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID projectId;
|
||||
/** 恢复、重试场景下关联的父 Run。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID parentRunId;
|
||||
/** 本次 Run 固定使用的模型配置。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID modelConfigId;
|
||||
/** INITIAL、RESUME 或 RETRY。 */
|
||||
private String triggerType;
|
||||
/** 当前运行状态。 */
|
||||
private String status;
|
||||
/** 等待用户确认时保存的 Ask JSON。 */
|
||||
@Column(jdbcType = JdbcType.OTHER, typeHandler = JsonbStringTypeHandler.class)
|
||||
private String pendingInterrupt;
|
||||
/** 跨日志追踪标识。 */
|
||||
private String traceId;
|
||||
/** 失败或中断错误码。 */
|
||||
private String errorCode;
|
||||
/** 面向用户的失败信息。 */
|
||||
private String errorMessage;
|
||||
/** Run 开始时间。 */
|
||||
private OffsetDateTime startedAt;
|
||||
/** 进入终态或等待态的时间。 */
|
||||
private OffsetDateTime endedAt;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 最后更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() { return id; }
|
||||
public void setId(UUID id) { this.id = id; }
|
||||
public UUID getProjectId() { return projectId; }
|
||||
public void setProjectId(UUID projectId) { this.projectId = projectId; }
|
||||
public UUID getParentRunId() { return parentRunId; }
|
||||
public void setParentRunId(UUID parentRunId) { this.parentRunId = parentRunId; }
|
||||
public UUID getModelConfigId() { return modelConfigId; }
|
||||
public void setModelConfigId(UUID modelConfigId) { this.modelConfigId = modelConfigId; }
|
||||
public String getTriggerType() { return triggerType; }
|
||||
public void setTriggerType(String triggerType) { this.triggerType = triggerType; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public String getPendingInterrupt() { return pendingInterrupt; }
|
||||
public void setPendingInterrupt(String pendingInterrupt) { this.pendingInterrupt = pendingInterrupt; }
|
||||
public String getTraceId() { return traceId; }
|
||||
public void setTraceId(String traceId) { this.traceId = traceId; }
|
||||
public String getErrorCode() { return errorCode; }
|
||||
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
public OffsetDateTime getStartedAt() { return startedAt; }
|
||||
public void setStartedAt(OffsetDateTime startedAt) { this.startedAt = startedAt; }
|
||||
public OffsetDateTime getEndedAt() { return endedAt; }
|
||||
public void setEndedAt(OffsetDateTime endedAt) { this.endedAt = endedAt; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
|
||||
|
||||
/**
|
||||
* 提供 Agent Run 查询以及带前置状态条件的原子状态迁移。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface AgentRunMapper extends BaseMapper<AgentRunEntity> {
|
||||
|
||||
/** 将等待输入的 Run 标记为已完成。 */
|
||||
int completeWaiting(@Param("runId") UUID runId);
|
||||
|
||||
/** 将运行中的 Run 标记为用户中断。 */
|
||||
int interruptRunning(@Param("runId") UUID runId);
|
||||
|
||||
/** 将运行中的 Run 切换为等待输入并保存 Ask。 */
|
||||
int waitForInput(@Param("runId") UUID runId, @Param("interruptJson") String interruptJson);
|
||||
|
||||
/** 将运行中的 Run 标记为成功完成。 */
|
||||
int completeRunning(@Param("runId") UUID runId);
|
||||
|
||||
/** 将运行中的 Run 标记为失败。 */
|
||||
int failRunning(@Param("runId") UUID runId, @Param("message") String message);
|
||||
|
||||
/** 启动恢复时将所有遗留 RUNNING 状态标记为进程重启中断。 */
|
||||
int interruptRunningAfterRestart();
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import java.io.IOException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
|
||||
/**
|
||||
* 驱动材料检验、规划 Ask 和自动编写 Run。
|
||||
*/
|
||||
@Service
|
||||
public class AgentRunService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
|
||||
private final AgentRunMapper runMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AgentExecutionService executionService;
|
||||
private final AgentOutputService outputService;
|
||||
private final AgentRunStore runStore;
|
||||
private final AgentEventService eventService;
|
||||
private final ProjectService projectService;
|
||||
private final ProjectFileService fileService;
|
||||
private final ArtifactService artifactService;
|
||||
private final ExecutorService executor;
|
||||
private final TransactionTemplate transactions;
|
||||
static final int MAX_OUTPUT_REPAIRS = 3;
|
||||
private final Semaphore runSlots;
|
||||
private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 创建 Agent Run 服务。
|
||||
*
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
* @param executionService Agent 执行服务
|
||||
* @param outputService Agent 结构化输出服务
|
||||
* @param runStore Run 状态存储
|
||||
* @param eventService 事件服务
|
||||
* @param projectService 项目服务
|
||||
* @param fileService 材料与工作区服务
|
||||
* @param artifactService 产物服务
|
||||
* @param applicationExecutor 虚拟线程执行器
|
||||
* @param transactions 编程式事务模板
|
||||
* @param maxConcurrentRuns 单进程同时运行的任务上限
|
||||
*/
|
||||
public AgentRunService(
|
||||
AgentRunMapper runMapper,
|
||||
ObjectMapper objectMapper,
|
||||
AgentExecutionService executionService,
|
||||
AgentOutputService outputService,
|
||||
AgentRunStore runStore,
|
||||
AgentEventService eventService,
|
||||
ProjectService projectService,
|
||||
ProjectFileService fileService,
|
||||
ArtifactService artifactService,
|
||||
ExecutorService applicationExecutor,
|
||||
TransactionTemplate transactions,
|
||||
@Value("${app.max-concurrent-runs:2}") int maxConcurrentRuns) {
|
||||
this.runMapper = runMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
this.executionService = executionService;
|
||||
this.outputService = outputService;
|
||||
this.runStore = runStore;
|
||||
this.eventService = eventService;
|
||||
this.projectService = projectService;
|
||||
this.fileService = fileService;
|
||||
this.artifactService = artifactService;
|
||||
this.executor = applicationExecutor;
|
||||
this.transactions = transactions;
|
||||
if (maxConcurrentRuns < 1) throw new IllegalArgumentException("app.max-concurrent-runs 必须大于 0");
|
||||
this.runSlots = new Semaphore(maxConcurrentRuns);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台启动材料检验。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 新 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView startMaterialCheck(UUID projectId, UUID userId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
RunView previous = latest(projectId);
|
||||
RunView run = runStore.create(projectId, "INITIAL", null);
|
||||
projectService.updateStatus(projectId, "MATERIAL_CHECK");
|
||||
afterCommit(run.id(), () -> executeMaterialRun(project, run, false), previous == null ? null : previous.id());
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认材料检验结果并后台生成建设规划。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param response 用户对材料缺口的处理结果
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 新规划 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView confirmMaterials(UUID projectId, JsonNode response, UUID userId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
|
||||
RunView waiting = runStore.requireWaiting(projectId, "material_check");
|
||||
if (!response.isObject() || !response.path("decisions").isArray()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口");
|
||||
}
|
||||
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", response);
|
||||
runStore.completeWaiting(waiting.id());
|
||||
RunView run = runStore.create(projectId, "RESUME", waiting.id());
|
||||
projectService.updateStatus(projectId, "PLANNING");
|
||||
afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false), waiting.id());
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在一个事务中确认规划并启动自动编写,重复提交返回既有结果。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param planId 规划 ID
|
||||
* @param confirmedPlan 用户确认后的规划
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 已确认规划及编写 Run
|
||||
*/
|
||||
@Transactional
|
||||
public ConfirmPlanResult confirmPlanAndStartWriting(
|
||||
UUID projectId,
|
||||
UUID planId,
|
||||
JsonNode confirmedPlan,
|
||||
UUID userId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
ProjectService.PlanView current = projectService.currentPlan(projectId);
|
||||
if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) {
|
||||
RunView existing = latest(projectId);
|
||||
if (existing != null && !"WAITING_INPUT".equals(existing.status())) {
|
||||
return new ConfirmPlanResult(current, existing);
|
||||
}
|
||||
}
|
||||
outputService.validateConfirmedPlan(confirmedPlan);
|
||||
RunView waiting = runStore.requireWaiting(projectId, "planning");
|
||||
ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, userId);
|
||||
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId));
|
||||
runStore.completeWaiting(waiting.id());
|
||||
RunView run = runStore.create(projectId, "RESUME", waiting.id());
|
||||
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), waiting.id());
|
||||
return new ConfirmPlanResult(plan, run);
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复已确认但尚未完成的自动编写任务。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 编写 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView startWriting(UUID projectId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
ProjectService.PlanView plan = projectService.currentPlan(projectId);
|
||||
if (plan == null || !"CONFIRMED".equals(plan.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "请先确认建设规划");
|
||||
}
|
||||
RunView latest = latest(projectId);
|
||||
if (latest != null && "RUNNING".equals(latest.status())) {
|
||||
return latest;
|
||||
}
|
||||
if (latest != null && "WAITING_INPUT".equals(latest.status())) {
|
||||
runStore.completeWaiting(latest.id());
|
||||
}
|
||||
RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id());
|
||||
projectService.updateStatus(projectId, "WRITING");
|
||||
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false), latest == null ? null : latest.id());
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即停止当前 Run,并保留项目阶段和工作区成果。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 已中断的 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView stop(UUID projectId, UUID userId) {
|
||||
projectService.require(projectId);
|
||||
|
||||
RunView run = latest(projectId);
|
||||
if (run == null || !"RUNNING".equals(run.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
|
||||
}
|
||||
int updated = runMapper.interruptRunning(run.id());
|
||||
requireTerminalUpdate(updated);
|
||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
|
||||
onCommit(() -> {
|
||||
RunControl control = activeRuns.get(run.id());
|
||||
if (control != null) {
|
||||
control.cancel();
|
||||
}
|
||||
});
|
||||
return runStore.require(run.id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已中断 Run 的原阶段继续,复用同一线程状态和工作区成果。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 新的恢复 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView resume(UUID projectId, UUID userId) {
|
||||
return resume(projectId, null, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已中断 Run 的原阶段继续,并可显式选择本次恢复使用的模型。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 替代模型;为空时使用当前默认模型
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 新的恢复 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView resume(UUID projectId, UUID modelConfigId, UUID userId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
|
||||
RunView interrupted = latest(projectId);
|
||||
if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
|
||||
}
|
||||
String phase = runStore.interruptedPhase(interrupted, project);
|
||||
RunView run = runStore.create(projectId, "RESUME", interrupted.id(), modelConfigId);
|
||||
projectService.updateStatus(projectId, phase);
|
||||
scheduleResume(project, run, userId, phase, interrupted.id());
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行中的任务切换到替代模型,并以新的恢复 Run 保留完整审计边界。
|
||||
*
|
||||
* <p>旧 Run 在事务内先进入中断状态,新 Run 再绑定目标模型;任一步失败都会整体回滚。
|
||||
* 目标 ID 可以与旧 Run 相同,以便模型配置被编辑后重新建立客户端并读取最新配置。
|
||||
* 提交后先取消旧模型流,再从原业务阶段启动新 Run,复用相同 threadId 和工作区。</p>
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 替代模型 ID
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 绑定替代模型的新恢复 Run
|
||||
*/
|
||||
@Transactional
|
||||
public RunView switchModel(UUID projectId, UUID modelConfigId, UUID userId) {
|
||||
ProjectService.ProjectView project = projectService.require(projectId);
|
||||
|
||||
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, current.id());
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按中断前业务阶段注册恢复任务,所有调用方必须处于创建恢复 Run 的事务中。
|
||||
*/
|
||||
private void scheduleResume(
|
||||
ProjectService.ProjectView project,
|
||||
RunView run,
|
||||
UUID userId,
|
||||
String phase,
|
||||
UUID previousRunId) {
|
||||
switch (phase) {
|
||||
case "MATERIAL_CHECK" -> afterCommit(
|
||||
run.id(), () -> executeMaterialRun(project, run, true), previousRunId);
|
||||
case "PLANNING" -> {
|
||||
JsonNode materialResponse = runStore.latestMaterialResponse(project.id());
|
||||
afterCommit(run.id(), () -> executePlanningRun(
|
||||
project, run, userId, materialResponse, true), previousRunId);
|
||||
}
|
||||
case "WRITING" -> {
|
||||
ProjectService.PlanView plan = projectService.currentPlan(project.id());
|
||||
if (plan == null || !"CONFIRMED".equals(plan.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
|
||||
}
|
||||
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true), previousRunId);
|
||||
}
|
||||
default -> throw new ApiException(
|
||||
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回项目最近一次 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return Run;未执行时为空
|
||||
*/
|
||||
public RunView latest(UUID projectId) {
|
||||
return runStore.latest(projectId);
|
||||
}
|
||||
|
||||
private void executeMaterialRun(ProjectService.ProjectView project, RunView run, boolean resuming) {
|
||||
try {
|
||||
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
|
||||
"threadId", project.threadId(), "runId", run.id(), "phase", "MATERIAL_CHECK"));
|
||||
List<ProjectFileService.FileView> files = fileService.list(project.id());
|
||||
String fileSummary = files.isEmpty()
|
||||
? "没有企业材料,当前仅有企业名称。"
|
||||
: files.stream().map(file -> file.relativePath() + "(" + file.extension() + ")")
|
||||
.collect(java.util.stream.Collectors.joining("、"));
|
||||
String prompt = """
|
||||
现在只执行材料检验。企业:%s;申报等级:%s。
|
||||
已上传材料:%s
|
||||
请递归读取 inputs/ 下材料,先调用相应文档 Skill,再调用企业画像、材料诊断与知识库 Skill,输出简洁的事实与缺口摘要。
|
||||
若结构化读取无法覆盖扫描页、流程图或关键版面,可按需调用 document_view;不要默认渲染全部页面。
|
||||
材料存在时不得虚构材料事实;知识库内容只能补充背景、政策和规划依据,不能冒充企业事实。
|
||||
完成首轮目录清点后,先创建满足下述结构的 JSON 初稿,再在读取过程中持续更新,避免把必需产物留到最后。
|
||||
结束前必须把检验结果写入 work/facts/material-check.json,使用 UTF-8 严格 JSON,不能包含 Markdown。
|
||||
JSON 必须包含 summary、completeness(0-100 整数)、confirmedFacts(字符串数组)和 missingItems(对象数组)。
|
||||
每个 missingItems 对象必须包含 id、label、reason、required。即使仅有企业名称,也要给出可继续规划的最小缺口清单。
|
||||
暂时不要生成建设规划或 DOCX。
|
||||
完成后仅输出材料覆盖、已确认事实和待确认缺口,不说明内部文件、格式、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), fileSummary);
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
ObjectNode report = readMaterialCheckWithRepair(project, run);
|
||||
ObjectNode ask = objectMapper.createObjectNode();
|
||||
ask.put("kind", "material_check");
|
||||
ask.put("interruptId", "materials-" + run.id());
|
||||
ask.put("title", "确认材料检验");
|
||||
ask.put("description", "确认缺口处理方式后生成建设规划");
|
||||
ask.set("report", report);
|
||||
finishWaiting(project.id(), run.id(), ask);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取材料检验结果;Agent 正常结束但未写出有效文件时,把校验错误反馈给同一线程继续修复。
|
||||
*
|
||||
* @param project 当前项目
|
||||
* @param run 当前 Run
|
||||
* @return 有效材料检验结果
|
||||
* @throws IOException 修复后产物仍无法读取时抛出
|
||||
*/
|
||||
private ObjectNode readMaterialCheckWithRepair(ProjectService.ProjectView project, RunView run)
|
||||
throws IOException {
|
||||
int repairRound = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return outputService.readMaterialCheck(project.id());
|
||||
} catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
|
||||
runStore.ensureRunning(run.id());
|
||||
if (repairRound >= MAX_OUTPUT_REPAIRS) {
|
||||
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
|
||||
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
|
||||
}
|
||||
repairRound++;
|
||||
log.warn("Agent 未生成有效材料检验结果,继续同一线程修复,runId={},round={}",
|
||||
run.id(), repairRound, exception);
|
||||
streamAgent(project, run, """
|
||||
材料检验结构化产物校验失败:%s
|
||||
请使用简体中文,优先复用当前上下文及 work/extracted 中已有结果,停止大范围补充探索。
|
||||
立即修复并写入 work/facts/material-check.json,使用 UTF-8 严格 JSON,再调用 read_file 复核。
|
||||
必须包含 summary、completeness、confirmedFacts、missingItems;每个 missingItems 对象必须包含
|
||||
id、label、reason、required。完成有效文件后再结束本轮。
|
||||
""".formatted(exception.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据材料确认结果自动生成建设规划并进入规划 Ask。
|
||||
*
|
||||
* @param project 企业项目
|
||||
* @param run 当前 Run
|
||||
* @param userId 当前用户 ID
|
||||
* @param materialResponse 材料确认结果
|
||||
*/
|
||||
private void executePlanningRun(
|
||||
ProjectService.ProjectView project,
|
||||
RunView run,
|
||||
UUID userId,
|
||||
JsonNode materialResponse,
|
||||
boolean resuming) {
|
||||
try {
|
||||
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
|
||||
"threadId", project.threadId(), "runId", run.id(), "phase", "PLANNING"));
|
||||
String prompt = """
|
||||
现在生成建设规划。企业:%s;申报等级:%s。
|
||||
材料检验确认结果:%s
|
||||
请调用知识库、差距分析和建设规划 Skill,自主形成与企业名称、行业线索和申报等级一致的方案。
|
||||
已有企业材料中的事实不得改写或补造;缺少事实时允许使用知识库形成明确标记的规划假设。
|
||||
结束前必须把建议规划写入 work/plans/proposed-plan.json,使用 UTF-8 严格 JSON,不能包含 Markdown。
|
||||
JSON 必须包含 coreDirection、collaborationDirection、factoryName、planningYears(1-10 的整数)、investmentRange、
|
||||
applicationLevel、scenarios(字符串数组)、aiScenarioCount(整数)和 assumptions(字符串数组)。
|
||||
暂时不要编写最终 DOCX。
|
||||
完成后仅输出用户确认规划所需的方向、周期、投资、场景和假设,不说明内部文件、格式、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), materialResponse.toString());
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
ObjectNode plan = readProposedPlanWithRepair(project, run);
|
||||
ProjectService.PlanView saved = projectService.saveDraftPlan(project.id(), plan, userId);
|
||||
ObjectNode ask = objectMapper.createObjectNode();
|
||||
ask.put("kind", "planning");
|
||||
ask.put("interruptId", "plan-" + saved.id());
|
||||
ask.put("title", "确认建设规划");
|
||||
ask.put("description", "确认后 Agent 将自主完成申报书编写与评审");
|
||||
ask.set("plan", plan);
|
||||
ask.put("planId", saved.id().toString());
|
||||
finishWaiting(project.id(), run.id(), ask);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取建议规划;结构无效时把原因反馈给同一线程继续修复。
|
||||
*
|
||||
* @param project 当前项目
|
||||
* @param run 当前 Run
|
||||
* @return 有效建设规划
|
||||
* @throws IOException 文件无法读取时抛出
|
||||
*/
|
||||
private ObjectNode readProposedPlanWithRepair(ProjectService.ProjectView project, RunView run)
|
||||
throws IOException {
|
||||
int repairRound = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return outputService.readProposedPlan(project.id());
|
||||
} catch (ApiException | IOException | tools.jackson.core.JacksonException exception) {
|
||||
runStore.ensureRunning(run.id());
|
||||
if (repairRound >= MAX_OUTPUT_REPAIRS) {
|
||||
throw new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "AGENT_OUTPUT_INVALID",
|
||||
"结构化结果连续修复失败,现有成果已保留,请检查材料或模型后继续");
|
||||
}
|
||||
repairRound++;
|
||||
log.warn("Agent 未生成有效建设规划,继续同一线程修复,runId={},round={}",
|
||||
run.id(), repairRound, exception);
|
||||
streamAgent(project, run, """
|
||||
建设规划结构化产物校验失败:%s
|
||||
请使用简体中文,复用当前上下文,立即修复 work/plans/proposed-plan.json 并调用 read_file 复核。
|
||||
必须包含 coreDirection、collaborationDirection、factoryName、planningYears(1-10 整数)、
|
||||
investmentRange、applicationLevel、scenarios(非空数组)、aiScenarioCount 和 assumptions。
|
||||
完成有效文件后再结束本轮。
|
||||
""".formatted(exception.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeWritingRun(
|
||||
ProjectService.ProjectView project,
|
||||
ProjectService.PlanView plan,
|
||||
RunView run,
|
||||
boolean resuming) {
|
||||
try {
|
||||
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
|
||||
"threadId", project.threadId(), "runId", run.id(), "phase", "WRITING"));
|
||||
String prompt = """
|
||||
建设规划已确认。企业:%s,申报等级:%s。
|
||||
确认规划 JSON:%s
|
||||
请自主完成完整申报书:调用分章编写、总结、评审和 docx Skill;按需调用百炼知识库。
|
||||
你拥有当前项目工作区的读、写和 shell 能力。最终文件必须保存到 artifacts/,扩展名为 .docx。
|
||||
任何有企业材料可核对的内容必须据实;缺少企业事实的内容允许结合知识库形成合理方案,且在 Word 原生批注中标明待确认。
|
||||
正文目标为 1 万至 2 万汉字。关键现状未知可以待确认;建设场景、技术路线、实施阶段、建议 KPI 和保障机制等未来规划必须结合材料、知识库和 Skill 充分展开,不能以待确认占位代替可合理形成的方案。
|
||||
最终事实审计必须逐句检查:未知企业现状使用“需确认是否……”或“待企业提供……”等非断言句式,严禁先写肯定事实再附待确认批注;企业承诺必须写为待签署或待提供。
|
||||
每个 Word 原生批注 ID 只能锚定一处,commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一问题在多处引用时必须复制批注正文并分配新的唯一 ID。
|
||||
即使 artifacts/ 已有旧稿,也必须为本轮重新生成并覆盖 DOCX,再执行结构、一致性和事实边界校验。
|
||||
请保持章节口径一致,完成评审后再交付。
|
||||
最终回复只说明申报书完成情况和用户需要关注的确认事项,不汇报内部文件、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), plan.plan().toString());
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
|
||||
JsonNode metadata = objectMapper.valueToTree(Map.of(
|
||||
"planVersion", plan.version(),
|
||||
"companyName", project.companyName(),
|
||||
"review", "DOCX 已通过结构与一致性校验"));
|
||||
publishAndComplete(project.id(), run, metadata);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void streamAgent(ProjectService.ProjectView project, RunView run, String prompt) {
|
||||
RunControl control = activeRuns.get(run.id());
|
||||
executionService.execute(
|
||||
project,
|
||||
run,
|
||||
"""
|
||||
用户可见正文和执行说明默认使用简体中文;代码、命令、文件路径、标准原文和专有名词可保留原语言。
|
||||
|
||||
""" + prompt,
|
||||
control == null ? reactor.core.publisher.Mono.never() : control.stopSignal.asMono(),
|
||||
() -> runStore.ensureRunning(run.id()),
|
||||
() -> runStore.isInterrupted(run.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行切换为等待输入。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param interrupt Ask 内容
|
||||
*/
|
||||
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
||||
int updated = runMapper.waitForInput(runId, interrupt.toString());
|
||||
requireTerminalUpdate(updated);
|
||||
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务成功提交后启动可中断的后台任务。
|
||||
*
|
||||
* @param runId Run ID
|
||||
* @param task 后台任务
|
||||
*/
|
||||
private void afterCommit(UUID runId, Runnable task) {
|
||||
afterCommit(runId, task, null);
|
||||
}
|
||||
|
||||
private void afterCommit(UUID runId, Runnable task, UUID previousRunId) {
|
||||
// ponytail: 单进程并发上限;部署多个后端副本时改用数据库租约统一占位。
|
||||
RunControl control;
|
||||
synchronized (activeRuns) {
|
||||
RunControl previous = previousRunId == null ? null : activeRuns.get(previousRunId);
|
||||
if (previous == null && !runSlots.tryAcquire()) {
|
||||
throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, "AGENT_CAPACITY_REACHED", "当前运行任务已达上限,请稍后重试");
|
||||
}
|
||||
control = new RunControl(previous);
|
||||
activeRuns.put(runId, control);
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
try {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
// 同一项目接续复用名额,但须等旧 Agent 关闭并保存快照后再读写工作区。
|
||||
control.previousFinished.join();
|
||||
task.run();
|
||||
} finally {
|
||||
release(runId, control);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException exception) {
|
||||
release(runId, control);
|
||||
fail(runStore.require(runId), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status != TransactionSynchronization.STATUS_COMMITTED) {
|
||||
release(runId, control);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void release(UUID runId, RunControl control) {
|
||||
synchronized (activeRuns) {
|
||||
if (!activeRuns.remove(runId, control)) return;
|
||||
if (control.slotUsers.decrementAndGet() == 0) runSlots.release();
|
||||
}
|
||||
// 回滚或提交任务失败也不能让后继任务越过仍在释放资源的祖先任务。
|
||||
control.previousFinished.thenRun(() -> control.finished.complete(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务成功提交后执行短操作。
|
||||
*
|
||||
* @param action 提交后操作
|
||||
*/
|
||||
private void onCommit(Runnable action) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户停止后的恢复 Run 增加上下文接续约束。
|
||||
*
|
||||
* @param prompt 当前阶段提示词
|
||||
* @param resuming 是否恢复执行
|
||||
* @return 最终提示词
|
||||
*/
|
||||
private String recoveryPrompt(String prompt, boolean resuming) {
|
||||
if (!resuming) {
|
||||
return prompt;
|
||||
}
|
||||
return """
|
||||
这是用户停止后的继续运行。恢复同一 threadId 的会话状态,并先读取 MEMORY.md、work、references、artifacts。
|
||||
复用已完成成果,从中断位置继续;不要重复已经完成的工具调用,先校验可能未完整写入的中间文件。
|
||||
|
||||
""" + prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已中断的 Run 不再写入失败终态。
|
||||
*
|
||||
* @param run 当前 Run
|
||||
* @param exception 执行异常
|
||||
*/
|
||||
private void failUnlessInterrupted(RunView run, Exception exception) {
|
||||
if (exception instanceof AgentExecutionService.RunInterruptedException || runStore.isInterrupted(run.id())) {
|
||||
log.info("Agent Run 已停止,runId={}", run.id());
|
||||
return;
|
||||
}
|
||||
fail(run, exception);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在一个事务中发布产物并标记运行正常完成。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param run 当前 Run
|
||||
* @param metadata 产物元数据
|
||||
*/
|
||||
private void publishAndComplete(UUID projectId, RunView run, JsonNode metadata) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
|
||||
projectId, run.id(), run.startedAt().toInstant(), metadata);
|
||||
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
|
||||
int updated = runMapper.completeRunning(run.id());
|
||||
requireTerminalUpdate(updated);
|
||||
projectService.updateStatus(projectId, "DELIVERED");
|
||||
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子标记运行失败并写入终止事件,已终止的 Run 不重复写事件。
|
||||
*
|
||||
* @param run 当前 Run
|
||||
* @param exception 执行异常
|
||||
*/
|
||||
private void fail(RunView run, Exception exception) {
|
||||
log.error("Agent Run 执行失败,runId={}", run.id(), exception);
|
||||
String message = exception instanceof ApiException && exception.getMessage() != null
|
||||
? exception.getMessage()
|
||||
: "Agent 执行失败,请稍后重试";
|
||||
try {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
int updated = runMapper.failRunning(run.id(), message);
|
||||
if (updated == 1) {
|
||||
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
|
||||
"code", exception instanceof ApiException api ? api.code() : "AGENT_RUN_FAILED", "message", message));
|
||||
projectService.updateStatus(run.projectId(), "FAILED");
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException eventException) {
|
||||
exception.addSuppressed(eventException);
|
||||
log.error("Agent 失败事件持久化失败,runId={}", run.id(), eventException);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验终态更新只命中当前运行中的 Run。
|
||||
*
|
||||
* @param updated 更新行数
|
||||
*/
|
||||
private void requireTerminalUpdate(int updated) {
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_FINISHED", "当前任务已经结束");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Run 视图。
|
||||
*
|
||||
* @param id Run ID
|
||||
* @param projectId 项目 ID
|
||||
* @param modelConfigId 本次 Run 固定绑定的模型配置 ID
|
||||
* @param triggerType 触发类型
|
||||
* @param status 运行状态
|
||||
* @param pendingInterrupt 待处理 Ask JSON
|
||||
* @param errorMessage 失败信息
|
||||
* @param startedAt 开始时间
|
||||
* @param endedAt 结束时间
|
||||
*/
|
||||
public record RunView(
|
||||
UUID id,
|
||||
UUID projectId,
|
||||
UUID modelConfigId,
|
||||
String triggerType,
|
||||
String status,
|
||||
String pendingInterrupt,
|
||||
String errorMessage,
|
||||
OffsetDateTime startedAt,
|
||||
OffsetDateTime endedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 规划确认与编写启动的原子操作结果。
|
||||
*
|
||||
* @param plan 已确认规划
|
||||
* @param run 编写 Run
|
||||
*/
|
||||
public record ConfirmPlanResult(ProjectService.PlanView plan, RunView run) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存活跃任务的流取消信号。
|
||||
*/
|
||||
private static final class RunControl {
|
||||
|
||||
private final Sinks.One<Void> stopSignal = Sinks.one();
|
||||
private final AtomicInteger slotUsers;
|
||||
private final CompletableFuture<Void> previousFinished;
|
||||
private final CompletableFuture<Void> finished = new CompletableFuture<>();
|
||||
|
||||
private RunControl(RunControl previous) {
|
||||
slotUsers = previous == null ? new AtomicInteger(1) : previous.slotUsers;
|
||||
if (previous != null) slotUsers.incrementAndGet();
|
||||
previousFinished = previous == null ? CompletableFuture.completedFuture(null) : previous.finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消 Agent 流订阅,停止继续输出和后续工具调用。
|
||||
*/
|
||||
private void cancel() {
|
||||
stopSignal.tryEmitEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
|
||||
/**
|
||||
* 集中读写 Agent Run 持久化状态。
|
||||
*/
|
||||
@Service
|
||||
public class AgentRunStore {
|
||||
|
||||
private final AgentRunMapper runMapper;
|
||||
private final AgentEventMapper eventMapper;
|
||||
private final ModelConfigMapper modelMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建 Run 状态存储。
|
||||
*
|
||||
* @param runMapper Agent Run Mapper
|
||||
* @param eventMapper Agent 事件 Mapper
|
||||
* @param modelMapper 模型配置 Mapper
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public AgentRunStore(
|
||||
AgentRunMapper runMapper,
|
||||
AgentEventMapper eventMapper,
|
||||
ModelConfigMapper modelMapper,
|
||||
ObjectMapper objectMapper) {
|
||||
this.runMapper = runMapper;
|
||||
this.eventMapper = eventMapper;
|
||||
this.modelMapper = modelMapper;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建无并发冲突的新 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param triggerType 触发类型
|
||||
* @param parentRunId 父 Run ID
|
||||
* @return 新 Run
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
|
||||
return create(projectId, triggerType, parentRunId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建绑定指定模型的新 Run;未指定模型时使用当前启用的默认模型。
|
||||
*
|
||||
* <p>模型在 Run 创建事务内完成解析并写入 {@code model_config_id}。后续默认模型切换
|
||||
* 只影响新 Run,不会悄悄改变已经开始的任务。</p>
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param triggerType 触发类型
|
||||
* @param parentRunId 父 Run ID
|
||||
* @param requestedModelId 用户显式选择的模型;为空时使用默认模型
|
||||
* @return 新 Run
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // 查询只投影模型 ID,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
public AgentRunService.RunView create(
|
||||
UUID projectId,
|
||||
String triggerType,
|
||||
UUID parentRunId,
|
||||
UUID requestedModelId) {
|
||||
QueryWrapper activeRuns = QueryWrapper.create()
|
||||
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||
.and(AgentRunEntity::getStatus).in("RUNNING", "WAITING_INPUT");
|
||||
long active = runMapper.selectCountByQuery(activeRuns);
|
||||
if (active > 0) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
|
||||
}
|
||||
QueryWrapper modelQuery = QueryWrapper.create()
|
||||
.select(ModelConfigEntity::getId);
|
||||
if (requestedModelId == null) {
|
||||
modelQuery.where(ModelConfigEntity::getDefaultModel).eq(true);
|
||||
} else {
|
||||
modelQuery.where(ModelConfigEntity::getId).eq(requestedModelId);
|
||||
}
|
||||
modelQuery.and(ModelConfigEntity::getEnabled).eq(true);
|
||||
ModelConfigEntity model = modelMapper.selectOneByQuery(modelQuery);
|
||||
if (model == null) {
|
||||
if (requestedModelId == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置并启用默认模型");
|
||||
}
|
||||
throw new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_AVAILABLE", "选择的模型不存在或已停用");
|
||||
}
|
||||
|
||||
// 应用层提前生成 Run 与追踪 ID;时间字段仍交由数据库默认值统一生成。
|
||||
AgentRunEntity entity = new AgentRunEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setProjectId(projectId);
|
||||
entity.setParentRunId(parentRunId);
|
||||
entity.setModelConfigId(model.getId());
|
||||
entity.setTriggerType(triggerType);
|
||||
entity.setStatus("RUNNING");
|
||||
entity.setTraceId(UUID.randomUUID().toString());
|
||||
runMapper.insertSelective(entity);
|
||||
return require(entity.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回项目最近 Run。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 最近 Run;不存在时为空
|
||||
*/
|
||||
public AgentRunService.RunView latest(UUID projectId) {
|
||||
QueryWrapper query = runViewQuery()
|
||||
.where(AgentRunEntity::getProjectId).eq(projectId)
|
||||
.orderBy(AgentRunEntity::getCreatedAt).desc()
|
||||
.limit(1);
|
||||
return toRunView(runMapper.selectOneByQuery(query));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 Run。
|
||||
*
|
||||
* @param id Run ID
|
||||
* @return Run
|
||||
*/
|
||||
public AgentRunService.RunView require(UUID id) {
|
||||
QueryWrapper query = runViewQuery()
|
||||
.where(AgentRunEntity::getId).eq(id);
|
||||
AgentRunService.RunView run = toRunView(runMapper.selectOneByQuery(query));
|
||||
if (run == null) {
|
||||
// 强制读取仅用于内部已知 ID;缺失表示持久化状态异常,而不是新增的 404 业务分支。
|
||||
throw new IllegalStateException("Agent Run 不存在: " + id);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成等待输入的 Run。
|
||||
*
|
||||
* @param runId Run ID
|
||||
*/
|
||||
public void completeWaiting(UUID runId) {
|
||||
int updated = runMapper.completeWaiting(runId);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前等待中的指定 Ask。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param kind Ask 类型
|
||||
* @return 等待中的 Run
|
||||
*/
|
||||
public AgentRunService.RunView requireWaiting(UUID projectId, String kind) {
|
||||
AgentRunService.RunView run = latest(projectId);
|
||||
if (run == null || !"WAITING_INPUT".equals(run.status()) || run.pendingInterrupt() == null) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_NOT_WAITING", "当前没有等待确认的内容");
|
||||
}
|
||||
try {
|
||||
if (!kind.equals(objectMapper.readTree(run.pendingInterrupt()).path("kind").asText())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致");
|
||||
}
|
||||
return run;
|
||||
} catch (JacksonException exception) {
|
||||
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Run 仍处于运行状态。
|
||||
*
|
||||
* @param runId Run ID
|
||||
*/
|
||||
public void ensureRunning(UUID runId) {
|
||||
String status = status(runId);
|
||||
if (status == null) {
|
||||
// 保持迁移前强制单条查询对缺失记录的未预期异常语义。
|
||||
throw new IllegalStateException("Agent Run 不存在: " + runId);
|
||||
}
|
||||
if (!"RUNNING".equals(status)) {
|
||||
throw new AgentExecutionService.RunInterruptedException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Run 是否已由用户停止。
|
||||
*
|
||||
* @param runId Run ID
|
||||
* @return 是否已停止
|
||||
*/
|
||||
public boolean isInterrupted(UUID runId) {
|
||||
return "INTERRUPTED".equals(status(runId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从持久化事件读取中断前阶段。
|
||||
*
|
||||
* @param run 已中断 Run
|
||||
* @param project 当前项目
|
||||
* @return 业务阶段
|
||||
*/
|
||||
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
|
||||
String phase = eventMapper.selectLatestStartedPhase(run.id());
|
||||
return phase == null ? project.status() : phase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取规划恢复所需的最近材料确认结果。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @return 材料确认 JSON
|
||||
*/
|
||||
public JsonNode latestMaterialResponse(UUID projectId) {
|
||||
String value = eventMapper.selectLatestMaterialResponseJson(projectId);
|
||||
if (value == null) {
|
||||
return objectMapper.createObjectNode();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readTree(value);
|
||||
} catch (JacksonException exception) {
|
||||
throw new ApiException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"MATERIAL_RESPONSE_INVALID",
|
||||
"材料确认记录无法读取");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库实体转换为稳定的对外 Run 视图。
|
||||
*
|
||||
* @param entity Run 实体;不存在时为空
|
||||
* @return Run 视图;不存在时为空
|
||||
*/
|
||||
private static AgentRunService.RunView toRunView(AgentRunEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return new AgentRunService.RunView(
|
||||
entity.getId(),
|
||||
entity.getProjectId(),
|
||||
entity.getModelConfigId(),
|
||||
entity.getTriggerType(),
|
||||
entity.getStatus(),
|
||||
entity.getPendingInterrupt(),
|
||||
entity.getErrorMessage(),
|
||||
entity.getStartedAt(),
|
||||
entity.getEndedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 BaseMapper 主键查询读取 Run 状态。
|
||||
*
|
||||
* @param runId Run ID
|
||||
* @return 当前状态;Run 不存在时为空
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // 这里只投影状态列,LambdaGetter 可变参数不会引入运行期类型风险。
|
||||
private String status(UUID runId) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(AgentRunEntity::getStatus)
|
||||
.where(AgentRunEntity::getId).eq(runId);
|
||||
AgentRunEntity run = runMapper.selectOneByQuery(query);
|
||||
return run == null ? null : run.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Agent Run 对外视图所需的最小字段投影。
|
||||
*
|
||||
* <p>运行视图不暴露模型配置、追踪标识和内部错误码,显式投影可避免每次轮询都读取无关列。</p>
|
||||
*
|
||||
* @return Run 视图字段查询构造器
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
private static QueryWrapper runViewQuery() {
|
||||
return QueryWrapper.create().select(
|
||||
AgentRunEntity::getId,
|
||||
AgentRunEntity::getProjectId,
|
||||
AgentRunEntity::getModelConfigId,
|
||||
AgentRunEntity::getTriggerType,
|
||||
AgentRunEntity::getStatus,
|
||||
AgentRunEntity::getPendingInterrupt,
|
||||
AgentRunEntity::getErrorMessage,
|
||||
AgentRunEntity::getStartedAt,
|
||||
AgentRunEntity::getEndedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import io.agentscope.core.agent.Agent;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.core.event.AgentEvent;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.MsgRole;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.middleware.MiddlewareBase;
|
||||
import io.agentscope.core.middleware.ModelCallInput;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/** 在每次模型调用前刷新正文语言约束,并保留被 AG-UI 转换丢失的异常原因。 */
|
||||
final class AgentRuntimeMiddleware implements MiddlewareBase {
|
||||
private volatile Throwable modelFailure;
|
||||
|
||||
@Override
|
||||
public Flux<AgentEvent> onModelCall(Agent agent, RuntimeContext context, ModelCallInput input,
|
||||
Function<ModelCallInput, Flux<AgentEvent>> next) {
|
||||
var messages = new ArrayList<>(input.messages());
|
||||
messages.add(Msg.builder().role(MsgRole.USER).name("system")
|
||||
.content(TextBlock.builder().text("""
|
||||
<system-reminder>
|
||||
除非用户明确要求其他语言,本次及后续面向用户的正文、进度说明和最终回复均使用简体中文。
|
||||
英文 Skill、工具说明和历史英文消息不改变此要求;代码、命令、路径、标准原文和专有名词保留原语言。
|
||||
只说明业务进展和必要确认事项,不输出工具选择、脚本调试或内部文件操作的过程旁白。
|
||||
</system-reminder>
|
||||
""").build())
|
||||
.metadata(Map.of(Msg.METADATA_SYNTHETIC, true, Msg.METADATA_REMINDER_KIND, "response_language"))
|
||||
.build());
|
||||
return Flux.defer(() -> {
|
||||
modelFailure = null;
|
||||
return next.apply(new ModelCallInput(messages, input.tools(), input.options(), input.model()))
|
||||
.doOnError(error -> modelFailure = error);
|
||||
});
|
||||
}
|
||||
|
||||
Throwable modelFailure() {
|
||||
return modelFailure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 状态随 APP_DATA_ROOT 持久化;按项目复制旧状态,保留原件且不覆盖已迁移会话。 */
|
||||
final class AgentStateFiles {
|
||||
static Path legacyRoot() {
|
||||
return Path.of(System.getProperty("agentscope.state.home",
|
||||
Path.of(System.getProperty("user.home"), ".agentscope", "state").toString()));
|
||||
}
|
||||
|
||||
static void migrateExisting(Path dataRoot) {
|
||||
Path projects = dataRoot.resolve("projects");
|
||||
if (!Files.isDirectory(projects)) return;
|
||||
try (var paths = Files.list(projects)) {
|
||||
for (Path project : paths.filter(Files::isDirectory).toList()) {
|
||||
String name = project.getFileName().toString();
|
||||
if (name.matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) {
|
||||
prepare(dataRoot, legacyRoot(), UUID.fromString(name));
|
||||
}
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法检查已有项目的 Agent 状态", exception);
|
||||
}
|
||||
}
|
||||
|
||||
static synchronized Path prepare(Path dataRoot, Path legacyRoot, UUID projectId) {
|
||||
Path root = dataRoot.toAbsolutePath().normalize().resolve("agent-state/smart-factory-agent");
|
||||
String session = "project-" + projectId;
|
||||
// AgentScope 2.0.1 将 SESSION 沙箱元数据放在独立的 Base64 URL 目录中。
|
||||
// 分别补迁,兼容此前只复制了会话的项目;保留已有沙箱状态(包括删除标记)。
|
||||
String sandboxSession = Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(("sandbox/session/" + session).getBytes(StandardCharsets.UTF_8));
|
||||
for (String slot : new String[]{sandboxSession, session}) {
|
||||
Path relative = Path.of("__anon__", slot);
|
||||
copyMissing(legacyRoot.resolve("smart-factory-agent").resolve(relative), root.resolve(relative));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
private static void copyMissing(Path source, Path target) {
|
||||
if (!Files.isDirectory(source) || Files.exists(target)) return;
|
||||
Path staging = null;
|
||||
try {
|
||||
Files.createDirectories(target.getParent());
|
||||
staging = Files.createTempDirectory(target.getParent(), ".migrating-");
|
||||
try (var files = Files.walk(source)) {
|
||||
for (Path file : files.toList()) {
|
||||
Path destination = staging.resolve(source.relativize(file));
|
||||
if (Files.isSymbolicLink(file)) throw new IOException("旧状态目录不能包含符号链接");
|
||||
if (Files.isDirectory(file)) Files.createDirectories(destination);
|
||||
else Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
}
|
||||
}
|
||||
Files.move(staging, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("无法迁移 Agent 会话状态,原状态已保留", exception);
|
||||
} finally {
|
||||
if (staging != null && Files.exists(staging)) {
|
||||
try (var files = Files.walk(staging)) {
|
||||
for (Path file : files.sorted(Comparator.reverseOrder()).toList()) Files.delete(file);
|
||||
} catch (IOException exception) {
|
||||
org.slf4j.LoggerFactory.getLogger(AgentStateFiles.class).warn("清理状态迁移临时目录失败", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.core.message.Base64Source;
|
||||
import io.agentscope.core.message.ContentBlock;
|
||||
import io.agentscope.core.message.ImageBlock;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import io.agentscope.core.tool.Tool;
|
||||
import io.agentscope.core.tool.ToolParam;
|
||||
import io.agentscope.harness.agent.HarnessAgent;
|
||||
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
|
||||
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
|
||||
import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
|
||||
import io.agentscope.harness.agent.filesystem.model.WriteResult;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
|
||||
/**
|
||||
* 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片。
|
||||
*/
|
||||
public final class DocumentViewTool {
|
||||
|
||||
private static final int TOOL_TIMEOUT_SECONDS = 180;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ProjectFileService fileService;
|
||||
private final UUID projectId;
|
||||
private volatile HarnessAgent harness;
|
||||
|
||||
/**
|
||||
* 创建文档视觉工具。
|
||||
*
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
public DocumentViewTool(ObjectMapper objectMapper, ProjectFileService fileService, UUID projectId) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.fileService = fileService;
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定拥有沙箱工作区的 Harness Agent。
|
||||
*
|
||||
* @param harness 当前工具所属的 Harness Agent
|
||||
*/
|
||||
void bind(HarnessAgent harness) {
|
||||
this.harness = harness;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一个或多个文档页面、幻灯片、工作表范围或图片,并把图片返回给多模态模型。
|
||||
*
|
||||
* @param views 查看请求;建议每次不超过 5 个,允许按任务需要分批调用
|
||||
* @param runtimeContext 当前运行上下文
|
||||
* @return 包含结构化结果和成功图片的工具观察
|
||||
*/
|
||||
@Tool(
|
||||
name = "document_view",
|
||||
description = "按需查看 PDF/DOCX 页、PPTX 幻灯片、XLS/XLSX 工作表范围或图片。"
|
||||
+ "先使用文档 Skill 做结构化读取,只有内容缺失、扫描件或视觉布局重要时再调用。"
|
||||
+ "views 支持多个请求,建议每次最多 5 个;单个失败不影响其他结果。",
|
||||
readOnly = true)
|
||||
public ToolResultBlock view(
|
||||
@ToolParam(
|
||||
name = "views",
|
||||
description = "查看请求数组。path 为工作区相对路径;PDF/DOCX/PPTX 可给 page,"
|
||||
+ "XLS/XLSX 可给 sheet 和 range;可选 dpi 72-220、format 为 png/jpeg。")
|
||||
List<ViewRequest> views,
|
||||
RuntimeContext runtimeContext) {
|
||||
if (views == null || views.isEmpty()) {
|
||||
return ToolResultBlock.text("document_view 执行失败:views 至少包含一个查看请求");
|
||||
}
|
||||
HarnessAgent activeHarness = harness;
|
||||
if (activeHarness == null) {
|
||||
return ToolResultBlock.text("document_view 执行失败:当前 Agent 没有 Harness 工作区");
|
||||
}
|
||||
AbstractFilesystem filesystem = activeHarness.getWorkspaceManager().getFilesystem();
|
||||
if (!(filesystem instanceof SandboxBackedFilesystem sandbox)) {
|
||||
return ToolResultBlock.text("document_view 执行失败:当前工作区不支持文档渲染");
|
||||
}
|
||||
|
||||
String directory = "work/tmp/document-view/" + UUID.randomUUID();
|
||||
String requestPath = directory + "/request.json";
|
||||
String resultPath = directory + "/result.json";
|
||||
try {
|
||||
WriteResult write = filesystem.write(
|
||||
runtimeContext,
|
||||
requestPath,
|
||||
objectMapper.writeValueAsString(Map.of("views", views)));
|
||||
if (!write.isSuccess()) {
|
||||
return ToolResultBlock.text("document_view 执行失败:" + write.error());
|
||||
}
|
||||
ExecuteResponse execution = sandbox.execute(
|
||||
runtimeContext,
|
||||
"python /opt/sandbox/document_view.py " + requestPath + " " + resultPath,
|
||||
TOOL_TIMEOUT_SECONDS);
|
||||
FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst();
|
||||
if (!downloaded.isSuccess()) {
|
||||
return ToolResultBlock.text("document_view 执行失败:"
|
||||
+ safeError(execution.output(), downloaded.error()));
|
||||
}
|
||||
|
||||
JsonNode result = objectMapper.readTree(new String(downloaded.content(), StandardCharsets.UTF_8));
|
||||
List<ContentBlock> output = new ArrayList<>();
|
||||
output.add(TextBlock.builder()
|
||||
.text("document_view_result=" + objectMapper.writeValueAsString(result))
|
||||
.build());
|
||||
List<Map<String, Object>> metadata = new ArrayList<>();
|
||||
for (JsonNode image : result.path("images")) {
|
||||
String path = image.path("path").asText();
|
||||
byte[] imageBytes;
|
||||
try {
|
||||
imageBytes = readPreview(path, image.path("sizeBytes").asLong(-1));
|
||||
} catch (IOException | ApiException exception) {
|
||||
output.add(TextBlock.builder().text("预览图读取失败:"
|
||||
+ safeError(exception.getMessage(), null)).build());
|
||||
continue;
|
||||
}
|
||||
output.add(ImageBlock.builder()
|
||||
.source(Base64Source.builder()
|
||||
.mediaType(image.path("mediaType").asText("image/png"))
|
||||
.data(Base64.getEncoder().encodeToString(imageBytes))
|
||||
.build())
|
||||
.build());
|
||||
metadata.add(objectMapper.convertValue(image, new TypeReference<LinkedHashMap<String, Object>>() { }));
|
||||
}
|
||||
return ToolResultBlock.of(output, Map.of("images", metadata));
|
||||
} catch (Exception exception) {
|
||||
return ToolResultBlock.text("document_view 执行失败:" + safeError(exception.getMessage(), null));
|
||||
}
|
||||
}
|
||||
|
||||
byte[] readPreview(String path, long expectedSize) throws IOException {
|
||||
if (expectedSize <= 0 || expectedSize > 4 * 1024 * 1024) {
|
||||
throw new IOException("预览图大小无效或超过 4 MB");
|
||||
}
|
||||
// work 已绑定宿主机;复用预览路径校验,避免 SDK 的 512 KB stdout 截断 Base64。
|
||||
try (var input = fileService.preview(projectId, path).resource().getInputStream()) {
|
||||
byte[] bytes = input.readNBytes((int) expectedSize + 1);
|
||||
if (bytes.length != expectedSize) {
|
||||
throw new IOException("预览图字节数不一致,请重新渲染");
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择并限制返回给 Agent 的诊断信息。
|
||||
*
|
||||
* @param primary 首选错误信息
|
||||
* @param fallback 备用错误信息
|
||||
* @return 有界错误文本
|
||||
*/
|
||||
private String safeError(String primary, String fallback) {
|
||||
String value = primary == null || primary.isBlank() ? fallback : primary;
|
||||
if (value == null || value.isBlank()) {
|
||||
return "未返回可诊断信息,请缩小查看范围后重试";
|
||||
}
|
||||
value = value.strip();
|
||||
return value.length() > 600 ? value.substring(value.length() - 600) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个文档查看参数。
|
||||
*
|
||||
* @param path 工作区相对路径
|
||||
* @param page 一基页码或幻灯片编号
|
||||
* @param sheet 工作表名称
|
||||
* @param range Excel A1 范围
|
||||
* @param dpi 渲染 DPI
|
||||
* @param format png 或 jpeg
|
||||
*/
|
||||
public record ViewRequest(
|
||||
@ToolParam(name = "path", description = "工作区相对路径") String path,
|
||||
@ToolParam(name = "page", required = false, description = "一基页码或幻灯片编号") Integer page,
|
||||
@ToolParam(name = "sheet", required = false, description = "Excel 工作表名称") String sheet,
|
||||
@ToolParam(name = "range", required = false, description = "Excel A1 范围,例如 A1:H30") String range,
|
||||
@ToolParam(name = "dpi", required = false, description = "渲染 DPI,建议 120-180") Integer dpi,
|
||||
@ToolParam(name = "format", required = false, description = "png 或 jpeg") String format) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.core.tool.Tool;
|
||||
import io.agentscope.core.tool.ToolParam;
|
||||
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
|
||||
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
|
||||
import io.agentscope.harness.agent.filesystem.model.ReadResult;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
|
||||
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 提供带明确续读位置的文件读取工具,避免沙箱输出上限造成静默截断。
|
||||
*/
|
||||
final class PagedReadFileTool {
|
||||
|
||||
static final int DEFAULT_PAGE_LINES = 400;
|
||||
private static final int SAFE_PAGE_BYTES = 300 * 1024;
|
||||
|
||||
private final AbstractFilesystem filesystem;
|
||||
private final WorkspacePathNormalizer pathNormalizer;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 创建分页读取工具。
|
||||
*
|
||||
* @param filesystem AgentScope 文件系统
|
||||
* @param pathNormalizer 工作区路径归一化器
|
||||
* @param objectMapper JSON 映射器
|
||||
*/
|
||||
PagedReadFileTool(
|
||||
AbstractFilesystem filesystem,
|
||||
WorkspacePathNormalizer pathNormalizer,
|
||||
ObjectMapper objectMapper) {
|
||||
this.filesystem = filesystem;
|
||||
this.pathNormalizer = pathNormalizer;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行读取文件;内容未读完时返回下一页 offset。
|
||||
*
|
||||
* @param runtimeContext Agent 运行上下文
|
||||
* @param path 文件路径
|
||||
* @param offset 起始行,基于零
|
||||
* @param limit 本页最多行数
|
||||
* @return 文件内容及必要的续读提示
|
||||
*/
|
||||
@Tool(
|
||||
name = "read_file",
|
||||
readOnly = true,
|
||||
description =
|
||||
"Read UTF-8 file content by lines. When content remains, the result explicitly"
|
||||
+ " provides nextOffset for the next call.")
|
||||
public String readFile(
|
||||
RuntimeContext runtimeContext,
|
||||
@ToolParam(name = "path", description = "File path to read") String path,
|
||||
@ToolParam(
|
||||
name = "offset",
|
||||
description = "Start line (0-indexed). Default: 0",
|
||||
required = false)
|
||||
Integer offset,
|
||||
@ToolParam(
|
||||
name = "limit",
|
||||
description = "Max lines to return. Default: 400",
|
||||
required = false)
|
||||
Integer limit) {
|
||||
int start = offset == null ? 0 : offset;
|
||||
int pageLines = limit == null || limit <= 0 ? DEFAULT_PAGE_LINES : limit;
|
||||
if (start < 0) {
|
||||
return "Error: offset 不能小于 0";
|
||||
}
|
||||
String normalizedPath = pathNormalizer.normalize(path);
|
||||
if (filesystem instanceof AbstractSandboxFilesystem sandbox) {
|
||||
return readFromSandbox(sandbox, runtimeContext, normalizedPath, start, pageLines);
|
||||
}
|
||||
return readFromFilesystem(runtimeContext, normalizedPath, start, pageLines);
|
||||
}
|
||||
|
||||
private String readFromSandbox(
|
||||
AbstractSandboxFilesystem sandbox,
|
||||
RuntimeContext runtimeContext,
|
||||
String path,
|
||||
int offset,
|
||||
int limit) {
|
||||
String encodedPath = Base64.getEncoder().encodeToString(path.getBytes(StandardCharsets.UTF_8));
|
||||
String command = """
|
||||
python3 - <<'PY'
|
||||
import base64, json
|
||||
path = base64.b64decode('%s').decode('utf-8')
|
||||
offset = %d
|
||||
limit = %d
|
||||
cap = %d
|
||||
try:
|
||||
with open(path, 'rb') as source:
|
||||
text = source.read().decode('utf-8')
|
||||
lines = text.splitlines()
|
||||
start = min(offset, len(lines))
|
||||
selected = []
|
||||
size = 0
|
||||
line_too_long = False
|
||||
for line in lines[start:start + limit]:
|
||||
data = (line + '\\n').encode('utf-8')
|
||||
if size + len(data) > cap:
|
||||
line_too_long = not selected
|
||||
break
|
||||
selected.append(line)
|
||||
size += len(data)
|
||||
next_offset = start + len(selected)
|
||||
meta = {
|
||||
'ok': True,
|
||||
'truncated': next_offset < len(lines),
|
||||
'nextOffset': next_offset,
|
||||
'returnedLines': len(selected),
|
||||
'lineTooLong': line_too_long
|
||||
}
|
||||
print(json.dumps(meta, ensure_ascii=False, separators=(',', ':')))
|
||||
print(base64.b64encode('\\n'.join(selected).encode('utf-8')).decode('ascii'))
|
||||
except FileNotFoundError:
|
||||
print(json.dumps({'ok': False, 'error': 'file_not_found'}, separators=(',', ':')))
|
||||
except UnicodeDecodeError:
|
||||
print(json.dumps({'ok': False, 'error': 'not_utf8_text'}, separators=(',', ':')))
|
||||
except Exception as error:
|
||||
print(json.dumps({'ok': False, 'error': str(error)}, ensure_ascii=False, separators=(',', ':')))
|
||||
PY
|
||||
""".formatted(encodedPath, offset, limit, SAFE_PAGE_BYTES);
|
||||
ExecuteResponse response = sandbox.execute(runtimeContext, command, null);
|
||||
String output = response.output() == null ? "" : response.output();
|
||||
int split = output.indexOf('\n');
|
||||
String header = split < 0 ? output.strip() : output.substring(0, split).strip();
|
||||
try {
|
||||
JsonNode meta = objectMapper.readTree(header);
|
||||
if (!meta.path("ok").asBoolean()) {
|
||||
return switch (meta.path("error").asText()) {
|
||||
case "file_not_found" -> "Error: 文件不存在:" + path;
|
||||
case "not_utf8_text" -> "Error: 文件不是 UTF-8 文本,请调用相应文档 Skill 或 document_view";
|
||||
default -> "Error: 读取文件失败:" + meta.path("error").asText("未知错误");
|
||||
};
|
||||
}
|
||||
int nextOffset = meta.path("nextOffset").asInt(offset);
|
||||
if (response.truncated()) {
|
||||
return incompleteNotice(path, nextOffset, limit);
|
||||
}
|
||||
String encoded = split < 0 ? "" : output.substring(split + 1).strip();
|
||||
String content = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
|
||||
if (meta.path("lineTooLong").asBoolean()) {
|
||||
return "Error: 当前行超过安全读取范围,请使用 execute_shell_command 分段读取该行;内容未被静默截断";
|
||||
}
|
||||
return meta.path("truncated").asBoolean()
|
||||
? content + "\n\n" + incompleteNotice(path, nextOffset, limit)
|
||||
: content;
|
||||
} catch (Exception exception) {
|
||||
return "Error: 无法解析文件读取结果,内容可能未读完,请缩小 limit 后重试";
|
||||
}
|
||||
}
|
||||
|
||||
private String readFromFilesystem(
|
||||
RuntimeContext runtimeContext, String path, int offset, int limit) {
|
||||
ReadResult result = filesystem.read(runtimeContext, path, offset, limit + 1);
|
||||
if (!result.isSuccess()) {
|
||||
return "Error: " + result.error();
|
||||
}
|
||||
if (result.fileData() == null) {
|
||||
return "";
|
||||
}
|
||||
if (!"utf-8".equalsIgnoreCase(result.fileData().encoding())) {
|
||||
return result.fileData().content();
|
||||
}
|
||||
String[] lines = result.fileData().content().split("\\R", -1);
|
||||
if (lines.length <= limit) {
|
||||
return result.fileData().content();
|
||||
}
|
||||
return String.join("\n", java.util.Arrays.copyOf(lines, limit))
|
||||
+ "\n\n"
|
||||
+ incompleteNotice(path, offset + limit, limit);
|
||||
}
|
||||
|
||||
private String incompleteNotice(String path, int nextOffset, int limit) {
|
||||
return "[系统提示:内容未读完。请继续调用 read_file(path=\""
|
||||
+ path
|
||||
+ "\", offset="
|
||||
+ nextOffset
|
||||
+ ", limit="
|
||||
+ limit
|
||||
+ ")。]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import io.agentscope.harness.agent.sandbox.Sandbox;
|
||||
import io.agentscope.harness.agent.sandbox.SandboxState;
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient;
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
|
||||
import java.util.UUID;
|
||||
|
||||
/** AgentScope 2.0.1 在调用结束才保存容器 ID;进程中断后使用会话的稳定名称恢复。 */
|
||||
final class RecoverableDockerClient extends DockerSandboxClient {
|
||||
private final String image;
|
||||
|
||||
RecoverableDockerClient(String image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sandbox resume(SandboxState state) {
|
||||
if (state instanceof DockerSandboxState docker && docker.isContainerOwned()
|
||||
&& docker.getImage() != null && docker.getImage().startsWith("smart-factory-agent-runtime:")) {
|
||||
// SDK 恢复时沿用持久化镜像名;更名后保留原会话和快照,使用当前沙箱镜像。
|
||||
docker.setImage(image);
|
||||
}
|
||||
if (state instanceof DockerSandboxState docker && docker.isContainerOwned()
|
||||
&& docker.getSessionId() != null
|
||||
&& docker.getSessionId().matches("[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}")) {
|
||||
String name = "agentscope-sandbox-" + UUID.fromString(docker.getSessionId());
|
||||
if (name.equals(docker.getContainerName())) {
|
||||
// Docker 接受名称作为标识;不存在时仍由 SDK 从原快照重建,不丢弃会话。
|
||||
docker.setContainerId(name);
|
||||
}
|
||||
}
|
||||
return super.resume(state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
|
||||
/**
|
||||
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
|
||||
*/
|
||||
@Component
|
||||
public class RunRecoveryService {
|
||||
|
||||
private final AgentRunMapper runMapper;
|
||||
|
||||
/**
|
||||
* 创建恢复服务。
|
||||
*
|
||||
* @param runMapper Agent Run Mapper
|
||||
*/
|
||||
public RunRecoveryService(AgentRunMapper runMapper) {
|
||||
this.runMapper = runMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将仍为 RUNNING 的旧 Run 标记为已中断。
|
||||
*
|
||||
*/
|
||||
@Transactional
|
||||
public void recoverInterruptedRuns() {
|
||||
runMapper.interruptRunningAfterRestart();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.core.tool.Tool;
|
||||
import io.agentscope.core.tool.ToolParam;
|
||||
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
|
||||
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** 修正 2.0.1 的编辑命令换行,并保留 shell 管道的真实退出状态。 */
|
||||
final class SandboxTools {
|
||||
private final AbstractSandboxFilesystem filesystem;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
SandboxTools(AbstractSandboxFilesystem filesystem, ObjectMapper mapper) {
|
||||
this.filesystem = filesystem;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Tool(name = "execute", description = "执行 Bash 命令,启用 errexit 和 pipefail。返回 success、exitCode、output;失败时先诊断再继续,不得使用 || true 掩盖必要步骤的错误。")
|
||||
public Map<String, Object> execute(RuntimeContext context,
|
||||
@ToolParam(name = "command", description = "Shell 命令") String command,
|
||||
@ToolParam(name = "working_directory", description = "工作区内相对目录", required = false) String directory,
|
||||
@ToolParam(name = "timeout", description = "超时秒数,默认 30", required = false) Integer timeout) {
|
||||
if (command == null || command.isBlank()) return failure("命令不能为空");
|
||||
if (directory != null && !directory.isBlank()) {
|
||||
if (!relativePath(directory)) return failure("working_directory 必须是工作区内相对路径");
|
||||
command = "cd " + quote(directory) + "\n" + command;
|
||||
}
|
||||
ExecuteResponse result = filesystem.execute(context,
|
||||
"bash -e -o pipefail -c " + quote(command), timeout != null && timeout > 0 ? timeout : 30);
|
||||
Map<String, Object> output = new LinkedHashMap<>();
|
||||
output.put("success", result.isSuccess());
|
||||
output.put("exitCode", result.exitCode());
|
||||
output.put("output", result.output() == null ? "" : result.output());
|
||||
output.put("truncated", result.truncated());
|
||||
return output;
|
||||
}
|
||||
|
||||
@Tool(name = "edit_file", description = "在 UTF-8 文件内精确替换。先读取文件;old_string 必须非空且唯一,除非 replace_all=true。修改已有文件应使用此工具,write_file 仅创建新文件。")
|
||||
public Map<String, Object> editFile(RuntimeContext context,
|
||||
@ToolParam(name = "path", description = "工作区内文件路径") String path,
|
||||
@ToolParam(name = "old_string", description = "要替换的原文") String oldString,
|
||||
@ToolParam(name = "new_string", description = "替换后的文本") String newString,
|
||||
@ToolParam(name = "replace_all", description = "替换全部匹配,默认 false", required = false) Boolean replaceAll) {
|
||||
path = WorkspacePathNormalizer.of("/workspace").normalize(path);
|
||||
if (!relativePath(path)) return failure("path 必须是工作区内文件路径");
|
||||
if (oldString == null || oldString.isEmpty() || newString == null) return failure("old_string 不能为空,new_string 不能为 null");
|
||||
String payload = Base64.getEncoder().encodeToString(mapper.writeValueAsString(Map.of(
|
||||
"path", path, "old", oldString, "new", newString, "all", Boolean.TRUE.equals(replaceAll)))
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
// 数据单独编码,避免文本中的引号、换行或 shell 字符改变脚本。
|
||||
String command = """
|
||||
python3 - <<'PY'
|
||||
import base64, fcntl, json, os
|
||||
p = json.loads(base64.b64decode('%s'))
|
||||
try:
|
||||
root = os.path.realpath('.')
|
||||
path = os.path.realpath(p['path'])
|
||||
if os.path.commonpath([root, path]) != root:
|
||||
raise ValueError('文件必须位于工作区内')
|
||||
with open(path, 'r+', encoding='utf-8', newline='') as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
text = f.read()
|
||||
count = text.count(p['old'])
|
||||
if count == 0:
|
||||
raise ValueError('未找到原文,请重新读取文件后编辑')
|
||||
if count > 1 and not p['all']:
|
||||
raise ValueError('原文出现多次,请扩大匹配范围或使用 replace_all')
|
||||
updated = text.replace(p['old'], p['new'], -1 if p['all'] else 1)
|
||||
f.seek(0)
|
||||
f.write(updated)
|
||||
f.truncate()
|
||||
print(json.dumps({'success': True, 'path': p['path'], 'replacements': count if p['all'] else 1}, ensure_ascii=False))
|
||||
except (OSError, UnicodeError, ValueError) as e:
|
||||
print(json.dumps({'success': False, 'error': str(e)}, ensure_ascii=False))
|
||||
PY
|
||||
""".formatted(payload);
|
||||
ExecuteResponse result = filesystem.execute(context, command, 30);
|
||||
if (!result.isSuccess() || result.truncated()) return failure("编辑命令失败:" + result.output());
|
||||
try {
|
||||
return mapper.readValue(result.output(), new tools.jackson.core.type.TypeReference<Map<String, Object>>() { });
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IllegalStateException("无法解析编辑工具结果", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean relativePath(String path) {
|
||||
if (path == null || path.isBlank() || path.startsWith("~")) return false;
|
||||
try {
|
||||
Path value = Path.of(path);
|
||||
return !value.isAbsolute() && !value.normalize().startsWith("..");
|
||||
} catch (java.nio.file.InvalidPathException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> failure(String message) {
|
||||
return Map.of("success", false, "error", message);
|
||||
}
|
||||
|
||||
static String quote(String value) {
|
||||
return "'" + value.replace("'", "'\\''") + "'";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射应用自管的 {@code app.skill_config} 表。
|
||||
*
|
||||
* <p>Skill 正文和资源不属于该实体,它们继续由 AgentScope 的 PostgreSQL repository 管理。</p>
|
||||
*/
|
||||
@Table(value = "skill_config", schema = "app")
|
||||
public class SkillConfigEntity {
|
||||
|
||||
/** Skill 标准名称,同时引用 AgentScope Skill 主表。 */
|
||||
@Id(keyType = KeyType.None)
|
||||
private String skillName;
|
||||
/** Skill 包版本。 */
|
||||
private String version;
|
||||
/** BUILTIN 或 IMPORTED。 */
|
||||
private String sourceType;
|
||||
/** 是否允许 Agent 使用。 */
|
||||
private Boolean enabled;
|
||||
/** 是否禁止从界面编辑内容。 */
|
||||
private Boolean readOnly;
|
||||
/** Skill 包内容摘要。 */
|
||||
private String checksum;
|
||||
/** VALID 或 INVALID。 */
|
||||
private String validationStatus;
|
||||
/** 校验失败说明。 */
|
||||
private String validationMessage;
|
||||
/** 导入用户。 */
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID importedBy;
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public String getSkillName() { return skillName; }
|
||||
public void setSkillName(String skillName) { this.skillName = skillName; }
|
||||
public String getVersion() { return version; }
|
||||
public void setVersion(String version) { this.version = version; }
|
||||
public String getSourceType() { return sourceType; }
|
||||
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public Boolean getReadOnly() { return readOnly; }
|
||||
public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
|
||||
public String getChecksum() { return checksum; }
|
||||
public void setChecksum(String checksum) { this.checksum = checksum; }
|
||||
public String getValidationStatus() { return validationStatus; }
|
||||
public void setValidationStatus(String validationStatus) { this.validationStatus = validationStatus; }
|
||||
public String getValidationMessage() { return validationMessage; }
|
||||
public void setValidationMessage(String validationMessage) { this.validationMessage = validationMessage; }
|
||||
public UUID getImportedBy() { return importedBy; }
|
||||
public void setImportedBy(UUID importedBy) { this.importedBy = importedBy; }
|
||||
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
|
||||
|
||||
/**
|
||||
* 提供应用 Skill 配置 CRUD 及对 AgentScope Skill 元数据的只读联查。
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface SkillConfigMapper extends BaseMapper<SkillConfigEntity> {
|
||||
|
||||
/** 列出全部 Skill 联合视图。 */
|
||||
List<SkillViewRow> selectViews();
|
||||
|
||||
/** 按名称读取一个 Skill 联合视图。 */
|
||||
SkillViewRow selectView(@Param("name") String name);
|
||||
|
||||
/**
|
||||
* 按迁移前 SQL 的条件更新 Skill 启用状态,并由数据库生成更新时间。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @param enabled 是否启用
|
||||
* @return 受影响行数
|
||||
*/
|
||||
int updateEnabled(@Param("name") String name, @Param("enabled") boolean enabled);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 读取并校验标准 Skill 目录。
|
||||
*/
|
||||
@Component
|
||||
public class SkillPackageReader {
|
||||
|
||||
private static final Pattern FRONTMATTER = Pattern.compile("\\A---\\s*\\R(.*?)\\R---\\s*\\R", Pattern.DOTALL);
|
||||
private static final Pattern FIELD = Pattern.compile("(?m)^([A-Za-z][A-Za-z0-9_-]*):\\s*(.+?)\\s*$");
|
||||
|
||||
/**
|
||||
* 读取一个 Skill 目录。
|
||||
*
|
||||
* @param directory Skill 根目录
|
||||
* @param source 仓库来源
|
||||
* @return 已校验 Skill 包
|
||||
*/
|
||||
public SkillPackage read(Path directory, String source) {
|
||||
Path root = directory.toAbsolutePath().normalize();
|
||||
Path skillFile = root.resolve("SKILL.md");
|
||||
if (!Files.isRegularFile(skillFile)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FILE_MISSING", "Skill 缺少 SKILL.md");
|
||||
}
|
||||
try {
|
||||
List<Path> files = Files.walk(root)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(path -> !isHidden(root.relativize(path)))
|
||||
.sorted(Comparator.comparing(path -> normalize(root.relativize(path))))
|
||||
.toList();
|
||||
if (files.size() > 500) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_TOO_MANY_FILES", "Skill 文件数量超过限制");
|
||||
}
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
Map<String, String> resources = new HashMap<>();
|
||||
String content = null;
|
||||
for (Path file : files) {
|
||||
String relative = normalize(root.relativize(file));
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
String text = decodeUtf8(bytes, relative);
|
||||
digest.update(relative.getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) 0);
|
||||
digest.update(bytes);
|
||||
if (relative.equals("SKILL.md")) {
|
||||
content = text;
|
||||
} else {
|
||||
resources.put(relative, text);
|
||||
}
|
||||
}
|
||||
Map<String, String> frontmatter = frontmatter(content);
|
||||
String name = required(frontmatter, "name");
|
||||
String description = required(frontmatter, "description");
|
||||
String version = frontmatter.getOrDefault("version", "v1.0");
|
||||
AgentSkill skill = new AgentSkill(name, description, content, resources, source);
|
||||
return new SkillPackage(skill, version, HexFormat.of().formatHex(digest.digest()), files.size());
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_READ_FAILED", "Skill 文件读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> frontmatter(String content) {
|
||||
Matcher block = FRONTMATTER.matcher(content == null ? "" : content);
|
||||
if (!block.find()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_MISSING", "SKILL.md 缺少 YAML Frontmatter");
|
||||
}
|
||||
Map<String, String> values = new HashMap<>();
|
||||
Matcher field = FIELD.matcher(block.group(1));
|
||||
while (field.find()) {
|
||||
values.put(field.group(1), unquote(field.group(2).trim()));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private String required(Map<String, String> values, String key) {
|
||||
String value = values.get(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_INVALID", "SKILL.md 缺少 " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String decodeUtf8(byte[] bytes, String path) {
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
.toString();
|
||||
} catch (CharacterCodingException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_BINARY_RESOURCE", "Skill 资源必须是 UTF-8 文本:" + path);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHidden(Path relative) {
|
||||
for (Path part : relative) {
|
||||
if (part.toString().startsWith(".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String normalize(Path path) {
|
||||
return path.toString().replace('\\', '/');
|
||||
}
|
||||
|
||||
private String unquote(String value) {
|
||||
if (value.length() >= 2
|
||||
&& ((value.startsWith("\"") && value.endsWith("\""))
|
||||
|| (value.startsWith("'") && value.endsWith("'")))) {
|
||||
return value.substring(1, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已解析 Skill 包。
|
||||
*
|
||||
* @param skill AgentScope Skill
|
||||
* @param version 展示版本
|
||||
* @param checksum 目录校验和
|
||||
* @param fileCount 文件数
|
||||
*/
|
||||
public record SkillPackage(AgentSkill skill, String version, String checksum, int fileCount) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* AgentScope PostgreSQL Skill Repository 配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class SkillRepositoryConfig {
|
||||
|
||||
/**
|
||||
* 创建由 Flyway 管理表结构的 Skill 仓库。
|
||||
*
|
||||
* @param dataSource 数据源
|
||||
* @return 可写 Skill 仓库
|
||||
*/
|
||||
@Bean(destroyMethod = "close")
|
||||
public PostgresSkillRepository postgresSkillRepository(DataSource dataSource) {
|
||||
return PostgresSkillRepository.builder(dataSource)
|
||||
.schemaName("agentscope")
|
||||
.skillsTableName("agentscope_skills")
|
||||
.resourcesTableName("agentscope_skill_resources")
|
||||
.createIfNotExist(false)
|
||||
.writeable(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillViewRow;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 提供 Skill 查看、导入和启停能力。
|
||||
*/
|
||||
@Service
|
||||
public class SkillService {
|
||||
|
||||
private static final int MAX_ZIP_ENTRIES = 500;
|
||||
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
|
||||
|
||||
private final SkillConfigMapper skillMapper;
|
||||
private final PostgresSkillRepository repository;
|
||||
private final SkillPackageReader packageReader;
|
||||
private final AgentProperties properties;
|
||||
|
||||
/**
|
||||
* 创建 Skill 服务。
|
||||
*
|
||||
* @param skillMapper 应用 Skill 配置 Mapper
|
||||
* @param repository AgentScope PostgreSQL 仓库
|
||||
* @param packageReader Skill 包读取器
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public SkillService(
|
||||
SkillConfigMapper skillMapper,
|
||||
PostgresSkillRepository repository,
|
||||
SkillPackageReader packageReader,
|
||||
AgentProperties properties) {
|
||||
this.skillMapper = skillMapper;
|
||||
this.repository = repository;
|
||||
this.packageReader = packageReader;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 Skill 配置。
|
||||
*
|
||||
* @return Skill 列表
|
||||
*/
|
||||
public List<SkillView> list() {
|
||||
return skillMapper.selectViews().stream()
|
||||
.map(SkillService::toSkillView)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Skill 详情。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @return Skill 详情
|
||||
*/
|
||||
public SkillDetail require(String name) {
|
||||
SkillViewRow row = skillMapper.selectView(name);
|
||||
if (row == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||
}
|
||||
SkillView view = toSkillView(row);
|
||||
AgentSkill skill = repository.getSkill(name);
|
||||
if (skill == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
|
||||
}
|
||||
return new SkillDetail(view, skill.getSkillContent(), skill.getResourcePaths().stream().sorted().toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Skill 文本资源。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @param path 资源相对路径
|
||||
* @return 资源文本
|
||||
*/
|
||||
public String resource(String name, String path) {
|
||||
if (path == null || path.isBlank() || path.startsWith("/") || path.contains("..")) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_RESOURCE_PATH_INVALID", "Skill 资源路径无效");
|
||||
}
|
||||
AgentSkill skill = repository.getSkill(name);
|
||||
String resource = skill == null ? null : skill.getResource(path);
|
||||
if (resource == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_RESOURCE_NOT_FOUND", "Skill 资源不存在");
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Skill 启用状态。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
public void setEnabled(String name, boolean enabled) {
|
||||
int updated = skillMapper.updateEnabled(name, enabled);
|
||||
if (updated != 1) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 返回当前启用的 Skill 名称。
|
||||
*
|
||||
* @return Skill 名称数组
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public String[] enabledNames() {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(SkillConfigEntity::getSkillName)
|
||||
.where(SkillConfigEntity::getEnabled).eq(true)
|
||||
.and(SkillConfigEntity::getValidationStatus).eq("VALID")
|
||||
.orderBy(SkillConfigEntity::getSkillName).asc();
|
||||
return skillMapper.selectListByQuery(query).stream()
|
||||
.map(SkillConfigEntity::getSkillName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入管理员上传的标准 Skill ZIP。
|
||||
*
|
||||
* @param file ZIP 文件
|
||||
* @param userId 当前操作者 ID
|
||||
* @return 导入后的 Skill
|
||||
*/
|
||||
@Transactional
|
||||
public SkillView importZip(MultipartFile file, UUID userId) {
|
||||
if (file.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空");
|
||||
}
|
||||
Path importRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("skill-imports");
|
||||
try {
|
||||
Files.createDirectories(importRoot);
|
||||
Path temporary = Files.createTempDirectory(importRoot, "skill-");
|
||||
try {
|
||||
unzip(file, temporary);
|
||||
Path skillRoot = locateSkillRoot(temporary);
|
||||
SkillPackageReader.SkillPackage skillPackage = packageReader.read(skillRoot, "imported");
|
||||
String name = skillPackage.skill().getName();
|
||||
if (repository.skillExists(name)) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在");
|
||||
}
|
||||
repository.save(List.of(skillPackage.skill()), false);
|
||||
|
||||
SkillConfigEntity config = new SkillConfigEntity();
|
||||
config.setSkillName(name);
|
||||
config.setVersion(skillPackage.version());
|
||||
config.setSourceType("IMPORTED");
|
||||
config.setEnabled(false);
|
||||
config.setReadOnly(true);
|
||||
config.setChecksum(skillPackage.checksum());
|
||||
config.setValidationStatus("VALID");
|
||||
config.setImportedBy(userId);
|
||||
// Skill 名称由上传包提供,因此显式使用 WithPk 插入字符串主键。
|
||||
skillMapper.insertSelectiveWithPk(config);
|
||||
return require(name).view();
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_IMPORT_FAILED", "Skill 压缩包读取失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除管理员导入且已停用的 Skill。
|
||||
*
|
||||
* @param name Skill 名称
|
||||
*/
|
||||
@Transactional
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public void deleteImported(String name) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(SkillConfigEntity::getSourceType)
|
||||
.where(SkillConfigEntity::getSkillName).eq(name);
|
||||
SkillConfigEntity config = skillMapper.selectOneByQuery(query);
|
||||
if (config == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在");
|
||||
}
|
||||
if (!"IMPORTED".equals(config.getSourceType())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
|
||||
}
|
||||
repository.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压 Skill 包,并忽略 macOS 与 Python 生成的无关元数据。
|
||||
*
|
||||
* @param file ZIP 文件
|
||||
* @param destination 解压目录
|
||||
* @throws IOException ZIP 读取或文件写入失败时抛出
|
||||
*/
|
||||
static void unzip(MultipartFile file, Path destination) throws IOException {
|
||||
int entries = 0;
|
||||
long total = 0;
|
||||
Path root = destination.toAbsolutePath().normalize();
|
||||
try (InputStream source = file.getInputStream(); ZipInputStream zip = new ZipInputStream(source, java.nio.charset.StandardCharsets.UTF_8)) {
|
||||
ZipEntry entry;
|
||||
byte[] buffer = new byte[8192];
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
String entryName = entry.getName().replace('\\', '/');
|
||||
Path target = root.resolve(entryName).normalize();
|
||||
if (!target.startsWith(root)) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_PATH_INVALID", "Skill 压缩包包含越界路径");
|
||||
}
|
||||
if (isIgnoredArchiveEntry(entryName)) {
|
||||
continue;
|
||||
}
|
||||
if (++entries > MAX_ZIP_ENTRIES) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_TOO_MANY_FILES", "Skill 压缩包文件数量超过限制");
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(target);
|
||||
continue;
|
||||
}
|
||||
Files.createDirectories(target.getParent());
|
||||
try (java.io.OutputStream output = Files.newOutputStream(target)) {
|
||||
int read;
|
||||
while ((read = zip.read(buffer)) != -1) {
|
||||
total += read;
|
||||
if (total > MAX_UNCOMPRESSED_BYTES) {
|
||||
throw new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, "SKILL_ZIP_TOO_LARGE", "Skill 解压后大小超过限制");
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据唯一的 SKILL.md 定位 Skill 根目录。
|
||||
*
|
||||
* @param temporary ZIP 解压目录
|
||||
* @return Skill 根目录
|
||||
* @throws IOException 目录遍历失败时抛出
|
||||
*/
|
||||
static Path locateSkillRoot(Path temporary) throws IOException {
|
||||
if (Files.isRegularFile(temporary.resolve("SKILL.md"))) {
|
||||
return temporary;
|
||||
}
|
||||
List<Path> candidates;
|
||||
try (var paths = Files.walk(temporary)) {
|
||||
candidates = paths
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().equals("SKILL.md"))
|
||||
.map(Path::getParent)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
if (candidates.size() == 1) {
|
||||
return candidates.getFirst();
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中必须包含 SKILL.md");
|
||||
}
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中包含多个 Skill,请每次仅导入一个");
|
||||
}
|
||||
|
||||
private static boolean isIgnoredArchiveEntry(String entryName) {
|
||||
String lowerName = entryName.toLowerCase(Locale.ROOT);
|
||||
if (lowerName.endsWith(".pyc")) {
|
||||
return true;
|
||||
}
|
||||
for (String part : entryName.split("/")) {
|
||||
if (part.equals("__MACOSX")
|
||||
|| part.equals(".DS_Store")
|
||||
|| part.equals("__pycache__")
|
||||
|| part.startsWith("._")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void deleteTree(Path root) {
|
||||
if (root == null || !Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (Path path : Files.walk(root).sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// 临时目录清理失败不覆盖主要导入结果,后续维护任务可清理。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将跨 schema 的只读查询行转换成对外 Skill 视图。
|
||||
*/
|
||||
private static SkillView toSkillView(SkillViewRow row) {
|
||||
return new SkillView(
|
||||
row.getName(),
|
||||
row.getDescription(),
|
||||
row.getVersion(),
|
||||
row.getSourceType(),
|
||||
Boolean.TRUE.equals(row.getEnabled()),
|
||||
Boolean.TRUE.equals(row.getReadOnly()),
|
||||
row.getValidationStatus(),
|
||||
row.getValidationMessage(),
|
||||
row.getUpdatedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 列表视图。
|
||||
*
|
||||
* @param name 标准名称
|
||||
* @param description 描述
|
||||
* @param version 版本
|
||||
* @param sourceType 来源
|
||||
* @param enabled 是否启用
|
||||
* @param readOnly 是否只读
|
||||
* @param validationStatus 校验状态
|
||||
* @param validationMessage 校验信息
|
||||
* @param updatedAt 更新时间
|
||||
*/
|
||||
public record SkillView(
|
||||
String name,
|
||||
String description,
|
||||
String version,
|
||||
String sourceType,
|
||||
boolean enabled,
|
||||
boolean readOnly,
|
||||
String validationStatus,
|
||||
String validationMessage,
|
||||
OffsetDateTime updatedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 详情。
|
||||
*
|
||||
* @param view 基本信息
|
||||
* @param content SKILL.md 全文
|
||||
* @param resources 资源路径
|
||||
*/
|
||||
public record SkillDetail(SkillView view, String content, List<String> resources) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/**
|
||||
* 承载 AgentScope Skill 主表与应用 Skill 配置表只读联查结果。
|
||||
*/
|
||||
public class SkillViewRow {
|
||||
|
||||
private String name;
|
||||
private String description;
|
||||
private String version;
|
||||
private String sourceType;
|
||||
private Boolean enabled;
|
||||
private Boolean readOnly;
|
||||
private String validationStatus;
|
||||
private String validationMessage;
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getVersion() { return version; }
|
||||
public void setVersion(String version) { this.version = version; }
|
||||
public String getSourceType() { return sourceType; }
|
||||
public void setSourceType(String sourceType) { this.sourceType = sourceType; }
|
||||
public Boolean getEnabled() { return enabled; }
|
||||
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||
public Boolean getReadOnly() { return readOnly; }
|
||||
public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
|
||||
public String getValidationStatus() { return validationStatus; }
|
||||
public void setValidationStatus(String validationStatus) { this.validationStatus = validationStatus; }
|
||||
public String getValidationMessage() { return validationMessage; }
|
||||
public void setValidationMessage(String validationMessage) { this.validationMessage = validationMessage; }
|
||||
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.runtime.AgentEventMapper">
|
||||
|
||||
<!-- 显式结果映射避免 payload 列与实体 payloadJson 属性名称不同而丢失事件负载。 -->
|
||||
<resultMap id="agentEventResultMap" type="tech.easyflow.manuagent.agent.runtime.AgentEventEntity">
|
||||
<id property="id" column="id"/>
|
||||
<result property="projectId" column="project_id"
|
||||
typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/>
|
||||
<result property="runId" column="run_id"
|
||||
typeHandler="tech.easyflow.manuagent.common.typehandler.UuidTypeHandler"/>
|
||||
<result property="eventType" column="event_type"/>
|
||||
<result property="eventId" column="event_id"/>
|
||||
<result property="payloadJson" column="payload"
|
||||
typeHandler="tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
PostgreSQL INSERT ... RETURNING 同时完成写入和序号读取,不使用“先插入、再查最大值”
|
||||
这种在并发场景下会取错事件的实现。affectData 保留正确的事务与缓存语义。
|
||||
-->
|
||||
<select id="insertReturning" resultMap="agentEventResultMap" affectData="true" flushCache="true">
|
||||
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
|
||||
VALUES (
|
||||
#{event.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{event.runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{event.eventType},
|
||||
#{event.eventId},
|
||||
#{event.payloadJson, jdbcType=OTHER,
|
||||
typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler})
|
||||
<!-- 与迁移前 JDBC 返回字段一致;event_id 已完成持久化,但无需再次回传给业务层。 -->
|
||||
RETURNING id, project_id, run_id, event_type, payload, created_at
|
||||
</select>
|
||||
|
||||
<!-- PostgreSQL JSONB 运算仅封装在数据库适配层,业务服务不感知方言细节。 -->
|
||||
<select id="selectLatestStartedPhase" resultType="string">
|
||||
SELECT payload ->> 'phase'
|
||||
FROM app.agent_event
|
||||
WHERE run_id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND event_type = 'RUN_STARTED'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectLatestMaterialResponseJson" resultType="string">
|
||||
SELECT payload::text
|
||||
FROM app.agent_event
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND event_type = 'ASK_RESPONDED'
|
||||
AND jsonb_typeof(payload -> 'decisions') = 'array'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.runtime.AgentRunMapper">
|
||||
|
||||
<!-- 以下更新均将“当前状态”写进 WHERE,更新行数就是状态机竞争结果。 -->
|
||||
<update id="completeWaiting">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'WAITING_INPUT'
|
||||
</update>
|
||||
|
||||
<update id="interruptRunning">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
||||
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="waitForInput">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'WAITING_INPUT',
|
||||
pending_interrupt = #{interruptJson, jdbcType=OTHER,
|
||||
typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="completeRunning">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'COMPLETED', pending_interrupt = NULL,
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="failRunning">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'FAILED', pending_interrupt = NULL,
|
||||
error_code = 'AGENT_RUN_FAILED', error_message = #{message},
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'RUNNING'
|
||||
</update>
|
||||
|
||||
<update id="interruptRunningAfterRestart">
|
||||
UPDATE app.agent_run
|
||||
SET status = 'INTERRUPTED', pending_interrupt = NULL,
|
||||
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
|
||||
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'RUNNING'
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.artifact.ArtifactMapper">
|
||||
|
||||
<!--
|
||||
PostgreSQL 的 INSERT ... RETURNING 属于会修改数据的查询语句。
|
||||
affectData 与 flushCache 确保 MyBatis 按 DML 事务语义处理并清理一级缓存。
|
||||
-->
|
||||
<select id="upsert"
|
||||
resultType="tech.easyflow.manuagent.agent.artifact.ArtifactEntity"
|
||||
affectData="true"
|
||||
flushCache="true">
|
||||
INSERT INTO app.artifact(
|
||||
id, project_id, run_id, kind, name, relative_path, mime_type,
|
||||
size_bytes, sha256, metadata_json)
|
||||
VALUES (
|
||||
#{artifact.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{artifact.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{artifact.runId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{artifact.kind}, #{artifact.name}, #{artifact.relativePath}, #{artifact.mimeType},
|
||||
#{artifact.sizeBytes}, #{artifact.sha256},
|
||||
#{artifact.metadataJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler})
|
||||
ON CONFLICT (project_id, relative_path) DO UPDATE SET
|
||||
run_id = EXCLUDED.run_id,
|
||||
kind = EXCLUDED.kind,
|
||||
name = EXCLUDED.name,
|
||||
mime_type = EXCLUDED.mime_type,
|
||||
size_bytes = EXCLUDED.size_bytes,
|
||||
sha256 = EXCLUDED.sha256,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
published_at = CURRENT_TIMESTAMP
|
||||
<!-- 发布接口只需要产物视图字段,下载字段由独立下载查询按需读取。 -->
|
||||
RETURNING id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.model.ModelAssignmentMapper">
|
||||
|
||||
<!-- 角色为主键,单语句 upsert 避免并发设置默认模型时出现先查后写竞态。 -->
|
||||
<insert id="upsert">
|
||||
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
|
||||
VALUES (
|
||||
#{assignment.role},
|
||||
#{assignment.modelConfigId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{assignment.assignedBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler})
|
||||
ON CONFLICT (role) DO UPDATE SET
|
||||
model_config_id = EXCLUDED.model_config_id,
|
||||
assigned_by = EXCLUDED.assigned_by,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
</insert>
|
||||
|
||||
<delete id="deleteByModelConfigId">
|
||||
DELETE FROM app.model_assignment
|
||||
WHERE model_config_id = #{modelConfigId,
|
||||
typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
</mapper>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.model.ModelConfigMapper">
|
||||
|
||||
<!--
|
||||
JSONB 参数必须显式使用 JsonbStringTypeHandler。这样即使 Lambda Wrapper 在 Spring 初始化前
|
||||
触发了 MyBatis-Flex 的全局 TableInfo 缓存,模型写入仍不会退化为 VARCHAR 参数绑定。
|
||||
-->
|
||||
<insert id="insertModel">
|
||||
INSERT INTO app.model_config(
|
||||
id, name, provider, base_url, model_id,
|
||||
api_key_ciphertext, api_key_hint, key_version,
|
||||
config_json, capabilities_json, enabled, is_default, created_by)
|
||||
VALUES (
|
||||
#{model.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{model.name},
|
||||
#{model.provider},
|
||||
#{model.baseUrl},
|
||||
#{model.modelId},
|
||||
#{model.apiKeyCiphertext},
|
||||
#{model.apiKeyHint},
|
||||
#{model.keyVersion},
|
||||
#{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
#{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
COALESCE(#{model.enabled}, TRUE),
|
||||
COALESCE(#{model.defaultModel}, FALSE),
|
||||
#{model.createdBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler})
|
||||
</insert>
|
||||
|
||||
<!--
|
||||
API Key 为空表示保留已有密钥;更新时间统一由数据库生成,避免应用时钟和数据库时钟混用。
|
||||
-->
|
||||
<update id="updateModel">
|
||||
UPDATE app.model_config
|
||||
SET name = #{model.name},
|
||||
base_url = #{model.baseUrl},
|
||||
model_id = #{model.modelId},
|
||||
config_json = #{model.configJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
capabilities_json = #{model.capabilitiesJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
<if test="model.apiKeyCiphertext != null">
|
||||
api_key_ciphertext = #{model.apiKeyCiphertext},
|
||||
api_key_hint = #{model.apiKeyHint},
|
||||
key_version = #{model.keyVersion},
|
||||
</if>
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{model.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
|
||||
<!-- 以下两条语句保持迁移前的执行顺序和条件,不额外引入模型启用状态判断。 -->
|
||||
<update id="clearDefault">
|
||||
UPDATE app.model_config
|
||||
SET is_default = FALSE
|
||||
WHERE is_default
|
||||
</update>
|
||||
|
||||
<update id="setDefault">
|
||||
UPDATE app.model_config
|
||||
SET is_default = TRUE,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
|
||||
<update id="setEnabled">
|
||||
UPDATE app.model_config
|
||||
SET enabled = #{enabled},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
|
||||
<delete id="deleteModel">
|
||||
DELETE FROM app.model_config
|
||||
WHERE id = #{id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
</mapper>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.project.ProjectMapper">
|
||||
|
||||
<!-- 项目删除前必须先阻止仍在运行的任务。 -->
|
||||
<select id="hasRunningRun" resultType="boolean">
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM app.agent_run
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'RUNNING'
|
||||
)
|
||||
</select>
|
||||
|
||||
<!-- 以下删除顺序与外键依赖顺序一致,并由 ProjectService 的 Spring 事务统一提交或回滚。 -->
|
||||
<delete id="deleteEvents">
|
||||
DELETE FROM app.agent_event
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
<delete id="deleteArtifacts">
|
||||
DELETE FROM app.artifact
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
<delete id="deletePlans">
|
||||
DELETE FROM app.project_plan
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
<delete id="deleteFiles">
|
||||
DELETE FROM app.project_file
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
<delete id="deleteRuns">
|
||||
DELETE FROM app.agent_run
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</delete>
|
||||
|
||||
<update id="updateStatus">
|
||||
UPDATE app.project
|
||||
SET status = #{status}, version = version + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.project.ProjectPlanMapper">
|
||||
|
||||
<!--
|
||||
版本号计算与写入保持在同一条 PostgreSQL 语句内;唯一约束继续作为并发冲突的最终保护。
|
||||
-->
|
||||
<select id="insertNextDraft"
|
||||
resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity"
|
||||
affectData="true"
|
||||
flushCache="true">
|
||||
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
|
||||
SELECT
|
||||
#{plan.id, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
#{plan.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
COALESCE(MAX(plan_version), 0) + 1,
|
||||
'DRAFT',
|
||||
#{plan.planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
#{plan.createdBy, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
FROM app.project_plan
|
||||
WHERE project_id = #{plan.projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||
</select>
|
||||
|
||||
<select id="selectCurrent" resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity">
|
||||
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||
FROM app.project_plan
|
||||
WHERE project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 条件更新和 RETURNING 在同一语句中完成,避免确认状态检查与写入之间出现竞态。 -->
|
||||
<select id="confirmDraft"
|
||||
resultType="tech.easyflow.manuagent.agent.project.ProjectPlanEntity"
|
||||
affectData="true"
|
||||
flushCache="true">
|
||||
UPDATE app.project_plan
|
||||
SET status = 'CONFIRMED',
|
||||
plan_json = #{planJson, jdbcType=OTHER, typeHandler=tech.easyflow.manuagent.common.typehandler.JsonbStringTypeHandler},
|
||||
confirmed_by = #{userId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler},
|
||||
confirmed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = #{planId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND project_id = #{projectId, typeHandler=tech.easyflow.manuagent.common.typehandler.UuidTypeHandler}
|
||||
AND status = 'DRAFT'
|
||||
RETURNING id, project_id, plan_version, status, plan_json, confirmed_at, created_at
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="tech.easyflow.manuagent.agent.skill.SkillConfigMapper">
|
||||
|
||||
<!-- AgentScope 表严格只读;应用只在 app.skill_config 保存启停、来源和校验状态。 -->
|
||||
<sql id="skillViewColumns">
|
||||
s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
|
||||
c.validation_status, c.validation_message, c.updated_at
|
||||
</sql>
|
||||
|
||||
<select id="selectViews" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
|
||||
SELECT <include refid="skillViewColumns"/>
|
||||
FROM agentscope.agentscope_skills s
|
||||
JOIN app.skill_config c ON c.skill_name = s.name
|
||||
ORDER BY c.source_type, s.name
|
||||
</select>
|
||||
|
||||
<select id="selectView" resultType="tech.easyflow.manuagent.agent.skill.SkillViewRow">
|
||||
SELECT <include refid="skillViewColumns"/>
|
||||
FROM agentscope.agentscope_skills s
|
||||
JOIN app.skill_config c ON c.skill_name = s.name
|
||||
WHERE s.name = #{name}
|
||||
</select>
|
||||
|
||||
<!-- 保持迁移前 JDBC SQL 的过滤条件和数据库时间戳语义。 -->
|
||||
<update id="updateEnabled">
|
||||
UPDATE app.skill_config
|
||||
SET enabled = #{enabled},
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE skill_name = #{name}
|
||||
AND validation_status = 'VALID'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,41 @@
|
||||
# 角色
|
||||
|
||||
你是智能工厂申报材料 Agent。你负责使用企业材料、百炼知识库、已启用 Skills 和项目工作区,形成建设规划、申报书审阅稿和评审结果。
|
||||
|
||||
# 语言
|
||||
|
||||
默认使用简体中文进行用户可见回复、执行说明、事实台账、建设规划和申报书编写。仅代码、命令、文件路径、标准原文、产品型号及无法准确翻译的专有名词保留原语言;用户明确要求其他语言时才切换。
|
||||
|
||||
# 事实与规划边界
|
||||
|
||||
- C:企业材料或用户确认的企业事实。材料已经覆盖的字段必须忠实使用,不得改写、美化或用案例替换。
|
||||
- E:企业公开资料,必须保留来源并标记待企业确认。
|
||||
- R:政策、标准、行业方法和同行案例,只用于支撑规划。
|
||||
- P:基于 R 形成的未来规划、建议目标和测算假设。规划确认后成为全书统一基线。
|
||||
- U:缺失、冲突或无法核实的企业现状,写为待企业确认并进入 DOCX 原生批注。
|
||||
- C/E/R/P/U 仅用于内部事实台账,不得出现在面向用户的 DOCX;统一转换为“已确认 / 待核实 / 待确认”等可读口径。
|
||||
|
||||
材料未覆盖的建设内容,应主动形成具体、完整、可执行的 P。自由生成仅限未来规划,不得编造企业当前设备、系统、营收、能耗、认证和既有成效。
|
||||
未知企业现状必须使用“需确认是否……”“待企业提供……”等非断言句式。严禁先写成已发生、已具备或已承诺的肯定事实,再在句尾附“待确认”;真实性承诺也只能写为待签署或待提供。
|
||||
|
||||
# 自主执行
|
||||
|
||||
生成 DOCX 前,读取并运行 `/opt/sandbox/docx-example.cjs`,将验证文件写入 `work/tmp/`;复用其中与当前 docx 版本匹配的表格、段落和原生批注 API。`comments` 必须是带 `children` 的对象,表格行的 `children` 必须是一维 TableCell 数组。需要核对其他 API 时读取 `/opt/sandbox/node_modules/docx/dist/index.d.ts`,不要猜测构造参数,也不要通过 `require('docx/package.json')` 获取版本。先用 `node --check` 检查脚本语法,再生成并验证 DOCX。必要步骤失败时立即修复,不能用管道尾部成功或 `|| true` 掩盖失败。
|
||||
|
||||
1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。
|
||||
2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill,先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。
|
||||
3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。
|
||||
4. 判断输入与事实充分程度,主动检索知识库并选择必要业务 Skill。
|
||||
5. 先读企业材料,再用知识库补充政策、标准、行业方法和规划依据。缺少企业现状时保持未知,不得让知识库或同行案例冒充企业事实。
|
||||
6. 将事实写入 work/facts,将规划锚点写入 work/plans,将引用写入 references。
|
||||
材料检验阶段生成 `work/facts/material-check.json`;规划阶段生成 `work/plans/proposed-plan.json`。
|
||||
两者必须来自当前企业材料与本次知识库分析,不得套用固定企业方案。
|
||||
阶段任务指定的结构化文件是必需产物。获得最小事实后应先写入合法初稿,再随分析持续更新;不得把必需产物推迟到全部可选读取和视觉检查之后。
|
||||
7. 规划确认后保持名称、架构、场景、KPI、投资、周期和术语前后一致。
|
||||
8. 常规工作区读写、搜索和 Shell 可自主执行;所有操作限定在 Docker 工作区内。不得探测宿主机、读取凭证、修改系统配置或访问与任务无关的网络服务;知识库访问只通过已启用的 RAG Skill。
|
||||
9. 工具返回失败、非零退出码、参数错误或文件冲突时,将错误结果视为可诊断观察;分析原因,修正参数、路径或前置条件后重试,也可选择替代工具。单个工具失败不得直接结束 Run。只有安全策略拒绝、用户停止、模型重连耗尽或确认无可恢复路径时才终止,且不得返回假成功。
|
||||
10. 页面输出只保留业务结论、待确认事项和必要依据。不要汇报后台 JSON、内部相对或绝对路径、编码或 Markdown 格式、命令、Skill 名称、工具调用、校验过程,以及“已写入”“已保存到某文件”等内部执行细节;这些操作由执行信息流单独展示。
|
||||
|
||||
# 完成条件
|
||||
|
||||
只有规划已确认、事实与规划口径一致、未知企业事实已转为批注、DOCX 通过打开与结构校验后,才可发布产物。申报书正文目标为 1 万至 2 万汉字,应完整展开建设背景、现状与差距、总体架构、建设场景、数据与系统集成、实施路径、投资与效益、保障机制等内容。关键未知企业事实允许待确认;能够由标准、知识库、Skill 和已确认规划形成的未来场景、技术路径、阶段任务与建议指标必须充分写实,不得用批注或空泛表述代替正文。不得把 Markdown 表格分隔行写入 Word 表格。中文字体须使用英文族名 `SimSun`(正文)与 `SimHei`(标题),同一文本运行的 ascii、hAnsi 与 eastAsia 均须使用对应字体,避免跨平台渲染为方框。当前不要求生成目录,不要创建仅含 TOC 域且需要办公软件手动更新的空目录;须检查表格换行、页码与批注锚点。每个 Word 原生批注 ID 只能锚定一处,正文中对应的 commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一待确认问题若在多处出现,必须复制批注正文并为每处使用新的唯一 ID。
|
||||
@@ -0,0 +1,9 @@
|
||||
请使用简体中文将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
|
||||
|
||||
1. 企业名称、申报等级,以及 C(企业材料或用户确认的企业事实)、E(企业公开资料,保留来源并标记待企业确认)、R(政策、标准、行业方法和同行案例,仅支撑规划)、P(基于 R 的未来规划、建议目标和测算假设)、U(缺失、冲突或无法核实的企业现状)边界。
|
||||
2. 已确认并冻结的建设规划,包括建设方向、场景、KPI、投资区间、建设周期和版本。
|
||||
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
|
||||
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。
|
||||
5. 当前任务进度、失败原因、尚未完成的动作和最安全的下一步。
|
||||
|
||||
不得把知识库内容改写为企业事实,不得把规划建议改写为已建成现状。省略寒暄、重复过程和可从工作区重新读取的大段工具输出。
|
||||
@@ -0,0 +1,69 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactEntity;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
|
||||
/**
|
||||
* 验证产物查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的最小字段范围。
|
||||
*/
|
||||
class ArtifactServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 产物列表只应读取接口视图字段,不加载下载路径、MIME、摘要和创建时间。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyArtifactViewColumnsWhenListing() {
|
||||
ArtifactMapper mapper = mock(ArtifactMapper.class);
|
||||
ArtifactEntity artifact = new ArtifactEntity();
|
||||
artifact.setId(UUID.randomUUID());
|
||||
artifact.setProjectId(UUID.randomUUID());
|
||||
artifact.setSizeBytes(1L);
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(artifact));
|
||||
ArtifactService service = new ArtifactService(
|
||||
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
|
||||
|
||||
service.list(artifact.getProjectId());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("metadata_json", "published_at", "size_bytes")
|
||||
.doesNotContain("relative_path", "mime_type", "sha256", "created_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载查询只应读取完整性校验和资源响应所需的六个字段。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyStoredArtifactColumnsWhenDownloading() {
|
||||
ArtifactMapper mapper = mock(ArtifactMapper.class);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
ArtifactService service = new ArtifactService(
|
||||
mapper, mock(ProjectFileService.class), mock(DocxValidator.class));
|
||||
|
||||
assertThatThrownBy(() -> service.download(UUID.randomUUID()))
|
||||
.isInstanceOf(ApiException.class);
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("project_id", "relative_path", "mime_type", "size_bytes", "sha256")
|
||||
.doesNotContain("metadata_json", "published_at", "created_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tech.easyflow.manuagent.agent.artifact;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* 验证 DOCX 发布边界会拒绝伪装文件和批注错锚。
|
||||
*/
|
||||
class DocxValidatorTest {
|
||||
|
||||
private final DocxValidator validator = new DocxValidator();
|
||||
|
||||
/**
|
||||
* 验证最小有效 Word 文档可通过结构校验。
|
||||
*
|
||||
* @param directory 临时目录
|
||||
* @throws IOException DOCX 写入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldValidateMinimalDocx(@TempDir Path directory) throws IOException {
|
||||
Path docx = writeDocx(directory.resolve("valid.docx"), Map.of());
|
||||
|
||||
DocxValidator.ValidationResult result = validator.validate(docx);
|
||||
|
||||
assertThat(result.entryCount()).isEqualTo(3);
|
||||
assertThat(result.commentCount()).isZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证批注锚点与批注定义不一致时拒绝发布。
|
||||
*
|
||||
* @param directory 临时目录
|
||||
* @throws IOException DOCX 写入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldRejectBrokenCommentAnchor(@TempDir Path directory) throws IOException {
|
||||
Map<String, String> extras = new LinkedHashMap<>();
|
||||
extras.put("word/comments.xml", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
|
||||
</w:comments>
|
||||
""");
|
||||
extras.put("word/_rels/document.xml.rels", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
|
||||
</Relationships>
|
||||
""");
|
||||
Path docx = writeDocx(directory.resolve("broken.docx"), extras);
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(docx))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("批注");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证同一批注编号存在多个正文锚点时拒绝发布。
|
||||
*
|
||||
* @param directory 临时目录
|
||||
* @throws IOException DOCX 写入失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldRejectDuplicateCommentAnchor(@TempDir Path directory) throws IOException {
|
||||
Map<String, String> extras = new LinkedHashMap<>();
|
||||
extras.put("word/document.xml", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p>
|
||||
<w:commentRangeStart w:id="9"/><w:r><w:t>待确认</w:t></w:r><w:commentRangeEnd w:id="9"/>
|
||||
<w:r><w:commentReference w:id="9"/></w:r><w:r><w:commentReference w:id="9"/></w:r>
|
||||
</w:p></w:body>
|
||||
</w:document>
|
||||
""");
|
||||
extras.put("word/comments.xml", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:comments xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:comment w:id="9"><w:p><w:r><w:t>待确认</w:t></w:r></w:p></w:comment>
|
||||
</w:comments>
|
||||
""");
|
||||
extras.put("word/_rels/document.xml.rels", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="comments" Target="comments.xml"/>
|
||||
</Relationships>
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(writeDocx(directory.resolve("duplicate.docx"), extras)))
|
||||
.isInstanceOf(ApiException.class)
|
||||
.hasMessageContaining("重复");
|
||||
}
|
||||
|
||||
private Path writeDocx(Path path, Map<String, String> extras) throws IOException {
|
||||
Map<String, String> entries = new LinkedHashMap<>();
|
||||
entries.put("[Content_Types].xml", "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"/>");
|
||||
entries.put("_rels/.rels", "<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"/>");
|
||||
entries.put("word/document.xml", """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body><w:p><w:r><w:t>智能工厂申报书</w:t></w:r></w:p></w:body>
|
||||
</w:document>
|
||||
""");
|
||||
entries.putAll(extras);
|
||||
try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(path))) {
|
||||
for (Map.Entry<String, String> entry : entries.entrySet()) {
|
||||
zip.putNextEntry(new ZipEntry(entry.getKey()));
|
||||
zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package tech.easyflow.manuagent.agent.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 tools.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.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.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(KeyCipher.class, () -> mock(KeyCipher.class));
|
||||
context.registerBean(ObjectMapper.class, () -> tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
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),
|
||||
keyCipher,
|
||||
tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package tech.easyflow.manuagent.agent.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
|
||||
/**
|
||||
* 验证模型管理接口使用最小字段投影,不把加密 API Key 读入普通请求内存。
|
||||
*/
|
||||
class ModelServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 模型必须完全由管理接口和数据库维护,服务启动不得再通过 Key 文件自动灌入默认模型。
|
||||
*/
|
||||
@Test
|
||||
void shouldNotInitializeModelConfigurationFromApplicationFiles() {
|
||||
assertThat(ApplicationRunner.class.isAssignableFrom(ModelService.class)).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型列表只需要页面展示字段,查询 SQL 不得包含密文和密钥版本列。
|
||||
*/
|
||||
@Test
|
||||
void shouldExcludeEncryptedKeyFromModelListQuery() {
|
||||
ModelConfigMapper modelMapper = mock(ModelConfigMapper.class);
|
||||
when(modelMapper.selectListByQuery(any(QueryWrapper.class)))
|
||||
.thenReturn(List.of(modelViewEntity()));
|
||||
ModelService service = new ModelService(
|
||||
modelMapper,
|
||||
mock(ModelAssignmentMapper.class),
|
||||
mock(AgentRunMapper.class),
|
||||
mock(KeyCipher.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
List<ModelService.ModelView> models = service.list();
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(modelMapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(models).hasSize(1);
|
||||
assertThat(sql)
|
||||
.contains("api_key_hint", "capabilities_json", "is_default")
|
||||
.doesNotContain("api_key_ciphertext", "key_version");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造包含全部展示字段的实体,避免测试依赖数据库或模型密钥解密逻辑。
|
||||
*
|
||||
* @return 模拟数据库返回的安全投影实体
|
||||
*/
|
||||
private ModelConfigEntity modelViewEntity() {
|
||||
ModelConfigEntity entity = new ModelConfigEntity();
|
||||
entity.setId(UUID.randomUUID());
|
||||
entity.setName("测试模型");
|
||||
entity.setProvider("OPENAI_COMPATIBLE");
|
||||
entity.setBaseUrl("https://example.test");
|
||||
entity.setModelId("model");
|
||||
entity.setApiKeyHint("••••1234");
|
||||
entity.setConfigJson("{}");
|
||||
entity.setCapabilitiesJson("{\"contextWindow\":8192}");
|
||||
entity.setEnabled(true);
|
||||
entity.setDefaultModel(true);
|
||||
entity.setUpdatedAt(OffsetDateTime.now());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package tech.easyflow.manuagent.agent.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 tools.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.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelAssignmentMapper;
|
||||
import tech.easyflow.manuagent.agent.model.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, UUID.randomUUID()))
|
||||
.as("地址应被拒绝:%s", baseUrl)
|
||||
.isInstanceOfSatisfying(ApiException.class, exception ->
|
||||
assertThat(exception.code()).isEqualTo("MODEL_BASE_URL_INVALID"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建只执行输入校验所需路径的模型服务;无效 URL 应在任何持久化写入之前被拒绝。
|
||||
*
|
||||
* @return 配置了当前用户的模型服务
|
||||
*/
|
||||
private ModelService service() {
|
||||
|
||||
return new ModelService(
|
||||
mock(ModelConfigMapper.class),
|
||||
mock(ModelAssignmentMapper.class),
|
||||
mock(AgentRunMapper.class),
|
||||
mock(KeyCipher.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.config.AgentProperties;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileMapper;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/**
|
||||
* 验证项目工作区文件操作。
|
||||
*/
|
||||
class ProjectFileServiceTest {
|
||||
|
||||
@TempDir
|
||||
private Path temporaryDirectory;
|
||||
|
||||
/**
|
||||
* 验证删除项目时会递归删除其受控工作区。
|
||||
*
|
||||
* @throws Exception 测试文件创建失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldDeleteProjectWorkspace() throws Exception {
|
||||
AgentProperties properties = new AgentProperties(
|
||||
temporaryDirectory, Path.of("dashscope"),
|
||||
"test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
|
||||
ProjectFileService service = new ProjectFileService(
|
||||
mock(ProjectFileMapper.class), mock(ProjectService.class), properties);
|
||||
UUID projectId = UUID.randomUUID();
|
||||
Path file = service.projectRoot(projectId).resolve("inputs/company.txt");
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, "企业材料");
|
||||
|
||||
service.deleteWorkspace(projectId);
|
||||
|
||||
assertThat(service.projectRoot(projectId)).doesNotExist();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传路径保留文件夹与原文件名。
|
||||
*/
|
||||
@Test
|
||||
void shouldPreserveUploadedFolderPath() {
|
||||
ProjectFileService service = service();
|
||||
|
||||
assertThat(service.normalizeUploadPath("测试输入/场景一/设备清单.xlsx", "设备清单.xlsx"))
|
||||
.isEqualTo("inputs/测试输入/场景一/设备清单.xlsx");
|
||||
assertThat(service.normalizeUploadPath(null, "企业材料.pdf"))
|
||||
.isEqualTo("inputs/企业材料.pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传路径不能越出 inputs 工作区。
|
||||
*/
|
||||
@Test
|
||||
void shouldRejectUnsafeFolderPath() {
|
||||
ProjectFileService service = service();
|
||||
|
||||
assertThatThrownBy(() -> service.normalizeUploadPath("../企业材料.pdf", "企业材料.pdf"))
|
||||
.isInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> service.normalizeUploadPath("其他文件.pdf", "企业材料.pdf"))
|
||||
.isInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表应保持迁移前 SQL 的字段范围,避免加载存储文件名、摘要和上传人等内部列。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyFileViewColumnsWhenListing() {
|
||||
ProjectFileMapper mapper = mock(ProjectFileMapper.class);
|
||||
ProjectService projectService = mock(ProjectService.class);
|
||||
ProjectFileEntity file = new ProjectFileEntity();
|
||||
file.setId(UUID.randomUUID());
|
||||
file.setProjectId(UUID.randomUUID());
|
||||
file.setSizeBytes(1L);
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(file));
|
||||
ProjectFileService service = new ProjectFileService(
|
||||
mapper, projectService, properties());
|
||||
|
||||
service.list(file.getProjectId());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("original_name", "relative_path", "mime_type", "size_bytes", "created_at")
|
||||
.doesNotContain("stored_name", "sha256", "uploaded_by", "updated_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建使用临时数据目录的文件服务。
|
||||
*
|
||||
* @return 文件服务
|
||||
*/
|
||||
private ProjectFileService service() {
|
||||
return new ProjectFileService(
|
||||
mock(ProjectFileMapper.class), mock(ProjectService.class), properties());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建测试统一使用的应用配置。
|
||||
*
|
||||
* @return 指向临时数据目录的配置
|
||||
*/
|
||||
private AgentProperties properties() {
|
||||
return new AgentProperties(
|
||||
temporaryDirectory, Path.of("dashscope"),
|
||||
"test-master", "runtime:test", "bridge", Duration.ofMinutes(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tech.easyflow.manuagent.agent.project;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectEntity;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanEntity;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectPlanMapper;
|
||||
|
||||
/**
|
||||
* 验证项目查询在 MyBatis-Flex 迁移后保持原 JDBC SQL 的字段范围。
|
||||
*/
|
||||
class ProjectServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 项目列表只读取接口视图字段,不加载创建人等内部字段。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyProjectViewColumnsWhenListing() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class);
|
||||
ProjectEntity project = project();
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(project));
|
||||
ProjectService service = service(mapper);
|
||||
|
||||
service.list();
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
assertProjectViewProjection(queryCaptor.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* 单项目读取与列表共用同一接口投影,且仍按主键精确过滤。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyProjectViewColumnsWhenRequiringProject() {
|
||||
ProjectMapper mapper = mock(ProjectMapper.class);
|
||||
ProjectEntity project = project();
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(project);
|
||||
ProjectService service = service(mapper);
|
||||
|
||||
ProjectService.ProjectView view = service.require(project.getId());
|
||||
|
||||
assertThat(view.id()).isEqualTo(project.getId());
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
QueryWrapper query = queryCaptor.getValue();
|
||||
assertProjectViewProjection(query);
|
||||
assertThat(query.toSQL().toLowerCase(java.util.Locale.ROOT)).contains("where", "id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库存量规划 JSON 损坏仍应进入统一未预期异常路径,不新增业务错误码。
|
||||
*/
|
||||
@Test
|
||||
void shouldKeepInvalidStoredPlanJsonAsUnexpectedTechnicalFailure() {
|
||||
ProjectPlanMapper planMapper = mock(ProjectPlanMapper.class);
|
||||
ProjectPlanEntity plan = new ProjectPlanEntity();
|
||||
plan.setPlanJson("{invalid-json");
|
||||
when(planMapper.selectCurrent(any(UUID.class))).thenReturn(plan);
|
||||
ProjectService service = new ProjectService(
|
||||
mock(ProjectMapper.class), planMapper, tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
assertThatThrownBy(() -> service.currentPlan(UUID.randomUUID()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(ApiException.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建满足项目接口映射要求的最小实体。
|
||||
*
|
||||
* @return 项目实体
|
||||
*/
|
||||
private ProjectEntity project() {
|
||||
ProjectEntity project = new ProjectEntity();
|
||||
project.setId(UUID.randomUUID());
|
||||
project.setVersion(1L);
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建仅用于查询行为验证的项目服务。
|
||||
*
|
||||
* @param mapper 待验证的项目 Mapper
|
||||
* @return 项目服务
|
||||
*/
|
||||
private ProjectService service(ProjectMapper mapper) {
|
||||
return new ProjectService(
|
||||
mapper,
|
||||
mock(ProjectPlanMapper.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言查询只包含迁移前项目视图 SQL 使用的字段。
|
||||
*
|
||||
* @param query 待检查的 MyBatis-Flex 查询
|
||||
*/
|
||||
private void assertProjectViewProjection(QueryWrapper query) {
|
||||
String sql = query.toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains(
|
||||
"company_name",
|
||||
"project_name",
|
||||
"agui_thread_id",
|
||||
"application_level",
|
||||
"status",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at")
|
||||
.doesNotContain("created_by");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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 tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
|
||||
/**
|
||||
* 验证 Agent 事件回放查询在 ORM 迁移后保持原 JDBC 字段和游标语义。
|
||||
*/
|
||||
class AgentEventServiceQueryTest {
|
||||
|
||||
/**
|
||||
* 事件回放只读取响应所需字段,不加载仅用于外部追踪的事件标识。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyEventViewColumnsWhenListingAfterCursor() {
|
||||
AgentEventMapper mapper = mock(AgentEventMapper.class);
|
||||
AgentEventEntity event = new AgentEventEntity();
|
||||
event.setId(1L);
|
||||
event.setProjectId(UUID.randomUUID());
|
||||
event.setPayloadJson("{}");
|
||||
when(mapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(event));
|
||||
AgentEventService service = new AgentEventService(mapper, tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
service.listAfter(event.getProjectId(), 0L, 100);
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectListByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("project_id", "run_id", "event_type", "payload", "created_at")
|
||||
.doesNotContain("event_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
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.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
|
||||
import io.agentscope.core.agui.event.AguiEvent;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
import tech.easyflow.manuagent.agent.skill.SkillService;
|
||||
|
||||
/**
|
||||
* 验证 Agent 事件持久化的精简规则。
|
||||
*/
|
||||
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);
|
||||
|
||||
ProjectFileService files = mock(ProjectFileService.class);
|
||||
when(files.projectRoot(projectId)).thenReturn(Path.of("/tmp/project"));
|
||||
AgentExecutionService service = new AgentExecutionService(
|
||||
tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
factory,
|
||||
mock(AgentEventService.class),
|
||||
files,
|
||||
skillService, new tech.easyflow.manuagent.agent.config.AgentProperties(
|
||||
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMinutes(1)));
|
||||
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, java.time.OffsetDateTime.now(), null);
|
||||
|
||||
service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false);
|
||||
|
||||
verify(factory).create(eq(projectId), eq(modelId), any(String[].class));
|
||||
|
||||
when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError(
|
||||
"project-" + projectId, runId.toString(), "sandbox failed: /tmp/project sk-testsecret123", "AGENT_ERROR")));
|
||||
assertThatThrownBy(() -> service.execute(project, run, "执行测试", Mono.never(), () -> { }, () -> false))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("sandbox failed")
|
||||
.hasMessageNotContaining("/tmp/project")
|
||||
.hasMessageNotContaining("sk-testsecret123");
|
||||
verify(factory, times(2)).create(eq(projectId), eq(modelId), any(String[].class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证文档视觉结果保留图片路径元数据并丢弃 Base64 正文。
|
||||
*/
|
||||
@Test
|
||||
void shouldStripInlineImageDataFromPersistedToolResult() {
|
||||
String content = """
|
||||
document_view_result={"images":[{"path":"work/tmp/document-view/a/render-1.png"}]}
|
||||
{"type":"image","source":{"media_type":"image/png","data":"very-large-base64"}}
|
||||
""";
|
||||
|
||||
String result = AgentExecutionService.stripInlineImageData(content);
|
||||
|
||||
assertThat(result).contains("render-1.png");
|
||||
assertThat(result).doesNotContain("very-large-base64");
|
||||
}
|
||||
@Test
|
||||
void shouldRetryConvertedModelErrorsAndEnforceTotalDeadlineEvenWhileStreaming() {
|
||||
var factory = mock(AgentFactory.class);
|
||||
var handle = mock(AgentFactory.AgentHandle.class);
|
||||
var adapter = mock(AguiAgentAdapter.class);
|
||||
var runtime = mock(AgentRuntimeMiddleware.class);
|
||||
when(handle.adapter()).thenReturn(adapter);
|
||||
when(handle.runtime()).thenReturn(runtime);
|
||||
when(factory.create(any(), any(), any())).thenReturn(handle);
|
||||
var skills = mock(SkillService.class);
|
||||
when(skills.enabledNames()).thenReturn(new String[0]);
|
||||
var files = mock(ProjectFileService.class);
|
||||
when(files.projectRoot(any())).thenReturn(Path.of("/tmp/project"));
|
||||
var events = mock(AgentEventService.class);
|
||||
var properties = new tech.easyflow.manuagent.agent.config.AgentProperties(
|
||||
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofSeconds(5));
|
||||
var service = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
factory, events, files, skills, properties);
|
||||
var project = mock(ProjectService.ProjectView.class);
|
||||
var projectId = UUID.randomUUID();
|
||||
when(project.id()).thenReturn(projectId);
|
||||
when(project.threadId()).thenReturn("test");
|
||||
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
|
||||
"INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null);
|
||||
when(runtime.modelFailure()).thenReturn(new io.agentscope.core.model.transport.HttpTransportException("lost", 503, ""));
|
||||
when(adapter.run(any())).thenReturn(Flux.just(new AguiEvent.RunError("test", "run", "lost", "INTERNAL_ERROR")))
|
||||
.thenReturn(Flux.empty());
|
||||
service.execute(project, run, "开始", Mono.never(), () -> { }, () -> false);
|
||||
verify(factory, times(2)).create(any(), any(), any());
|
||||
verify(events).append(eq(projectId), eq(run.id()), eq("MODEL_RETRY"), any());
|
||||
|
||||
var shortService = new AgentExecutionService(tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
factory, events, files, skills, new tech.easyflow.manuagent.agent.config.AgentProperties(
|
||||
Path.of("/tmp"), null, "test", "runtime", "bridge", java.time.Duration.ofMillis(250)));
|
||||
var shortRun = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
|
||||
"INITIAL", "RUNNING", null, null, java.time.OffsetDateTime.now(), null);
|
||||
when(adapter.run(any())).thenReturn(Flux.interval(java.time.Duration.ofMillis(20))
|
||||
.map(i -> new AguiEvent.RunStarted("test", "run")));
|
||||
assertThatThrownBy(() -> shortService.execute(project, shortRun, "开始", Mono.never(), () -> { }, () -> false))
|
||||
.isInstanceOf(tech.easyflow.manuagent.common.ApiException.class).hasMessageContaining("运行时限");
|
||||
verify(handle, times(3)).close();
|
||||
assertThatThrownBy(() -> shortService.remainingTime(shortRun)).hasMessageContaining("运行时限");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
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 static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
import io.agentscope.core.model.transport.HttpTransportException;
|
||||
import io.agentscope.core.skill.AgentSkill;
|
||||
import java.nio.file.Path;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import tech.easyflow.manuagent.agent.artifact.ArtifactService;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectService;
|
||||
|
||||
/**
|
||||
* 验证 Agent Run 的模型重连边界。
|
||||
*/
|
||||
class AgentRunServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldRepairMalformedJsonInBothAgentOutputPaths() throws Exception {
|
||||
UUID projectId = UUID.randomUUID();
|
||||
var mapper = tools.jackson.databind.json.JsonMapper.builder().build();
|
||||
tools.jackson.core.JacksonException malformed;
|
||||
try {
|
||||
mapper.readTree("{invalid");
|
||||
throw new AssertionError("Malformed JSON accepted");
|
||||
} catch (tools.jackson.core.JacksonException exception) {
|
||||
malformed = exception;
|
||||
}
|
||||
var repaired = mapper.createObjectNode().put("summary", "已修复");
|
||||
var output = mock(AgentOutputService.class);
|
||||
when(output.readMaterialCheck(projectId)).thenThrow(malformed).thenReturn(repaired);
|
||||
when(output.readProposedPlan(projectId)).thenThrow(malformed).thenReturn(repaired);
|
||||
var execution = mock(AgentExecutionService.class);
|
||||
var store = mock(AgentRunStore.class);
|
||||
var project = mock(ProjectService.ProjectView.class);
|
||||
when(project.id()).thenReturn(projectId);
|
||||
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
|
||||
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
|
||||
var service = new AgentRunService(mock(AgentRunMapper.class), mapper, execution, output,
|
||||
store, mock(AgentEventService.class), mock(ProjectService.class),
|
||||
mock(ProjectFileService.class), mock(ArtifactService.class),
|
||||
mock(ExecutorService.class), mock(TransactionTemplate.class), 2);
|
||||
for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) {
|
||||
Object result = org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, method, project, run);
|
||||
assertThat(result).isSameAs(repaired);
|
||||
}
|
||||
verify(execution, org.mockito.Mockito.times(2)).execute(
|
||||
org.mockito.ArgumentMatchers.eq(project), org.mockito.ArgumentMatchers.eq(run),
|
||||
org.mockito.ArgumentMatchers.contains("修复"), org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
verify(store, org.mockito.Mockito.times(2)).ensureRunning(run.id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行中模型配置被编辑后,用户应能选择同一模型 ID 创建新的恢复 Run,
|
||||
* 使新 Agent 客户端重新读取数据库中的最新地址、模型标识和密钥。
|
||||
*/
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {1, 2})
|
||||
void shouldRestartRunningTaskWhenSameModelConfigurationWasUpdatedAtCapacity(int limit) {
|
||||
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);
|
||||
|
||||
when(projectService.require(projectId)).thenReturn(project);
|
||||
|
||||
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,
|
||||
tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
mock(AgentExecutionService.class),
|
||||
mock(AgentOutputService.class),
|
||||
runStore,
|
||||
mock(AgentEventService.class),
|
||||
projectService,
|
||||
mock(ProjectFileService.class),
|
||||
mock(ArtifactService.class),
|
||||
mock(ExecutorService.class),
|
||||
mock(TransactionTemplate.class), limit);
|
||||
|
||||
// 服务会注册“提交后取消旧流并启动新流”的回调;测试只验证注册前的事务内状态转换。
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
for (int i = 0; i < limit; i++) {
|
||||
invokeMethod(service, "afterCommit", i == 0 ? currentRunId : UUID.randomUUID(), (Runnable) () -> {});
|
||||
}
|
||||
assertThat(service.switchModel(projectId, modelId, UUID.randomUUID())).isEqualTo(replacement);
|
||||
verify(runMapper).interruptRunning(currentRunId);
|
||||
verify(runStore).create(projectId, "RESUME", currentRunId, modelId);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach(sync ->
|
||||
sync.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldWaitForPreviousTaskCleanupBeforeStartingReplacement() throws Exception {
|
||||
var queue = new LinkedBlockingQueue<Runnable>();
|
||||
var executor = mock(ExecutorService.class);
|
||||
when(executor.submit(any(Runnable.class))).thenAnswer(call -> {
|
||||
queue.add(call.getArgument(0));
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
});
|
||||
var service = schedulingService(executor, mock(AgentRunStore.class));
|
||||
var previous = UUID.randomUUID();
|
||||
var replacementStarted = new CountDownLatch(1);
|
||||
transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true);
|
||||
Runnable previousTask = queue.remove();
|
||||
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(),
|
||||
(Runnable) replacementStarted::countDown, previous), true);
|
||||
var replacement = Thread.startVirtualThread(queue.remove());
|
||||
try {
|
||||
assertThat(replacementStarted.await(200, TimeUnit.MILLISECONDS)).isFalse();
|
||||
assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit",
|
||||
UUID.randomUUID(), (Runnable) () -> {}), true)).hasMessageContaining("已达上限");
|
||||
} finally {
|
||||
previousTask.run();
|
||||
replacement.join(5_000);
|
||||
}
|
||||
assertThat(replacement.isAlive()).isFalse();
|
||||
assertThat(replacementStarted.getCount()).isZero();
|
||||
// 接续完成后恢复一个名额。
|
||||
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void shouldRetainPredecessorCapacityWhenReplacementRollsBackOrIsRejected(boolean rejected) {
|
||||
var executor = mock(ExecutorService.class);
|
||||
var queue = new LinkedBlockingQueue<Runnable>();
|
||||
when(executor.submit(any(Runnable.class))).thenAnswer(call -> {
|
||||
queue.add(call.getArgument(0));
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(null);
|
||||
});
|
||||
var store = mock(AgentRunStore.class);
|
||||
var previous = UUID.randomUUID();
|
||||
var replacement = UUID.randomUUID();
|
||||
when(store.require(replacement)).thenReturn(new AgentRunService.RunView(replacement,
|
||||
UUID.randomUUID(), UUID.randomUUID(), "RESUME", "RUNNING", null, null, OffsetDateTime.now(), null));
|
||||
var service = schedulingService(executor, store);
|
||||
transaction(() -> invokeMethod(service, "afterCommit", previous, (Runnable) () -> {}), true);
|
||||
Runnable previousTask = queue.remove();
|
||||
if (rejected) when(executor.submit(any(Runnable.class))).thenThrow(new RejectedExecutionException("test"));
|
||||
transaction(() -> invokeMethod(service, "afterCommit", replacement, (Runnable) () -> {}, previous), rejected);
|
||||
assertThatThrownBy(() -> transaction(() -> invokeMethod(service, "afterCommit",
|
||||
UUID.randomUUID(), (Runnable) () -> {}), false)).hasMessageContaining("已达上限");
|
||||
previousTask.run();
|
||||
transaction(() -> invokeMethod(service, "afterCommit", UUID.randomUUID(), (Runnable) () -> {}), false);
|
||||
}
|
||||
|
||||
private static AgentRunService schedulingService(ExecutorService executor, AgentRunStore store) {
|
||||
return new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
mock(AgentExecutionService.class), mock(AgentOutputService.class), store,
|
||||
mock(AgentEventService.class), mock(ProjectService.class), mock(ProjectFileService.class),
|
||||
mock(ArtifactService.class), executor, mock(TransactionTemplate.class), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldShareCapacityWhenRestartingMaterialCheckWhileStoppedRunIsClosing() {
|
||||
UUID project = UUID.randomUUID();
|
||||
var previous = new AgentRunService.RunView(UUID.randomUUID(), project, UUID.randomUUID(),
|
||||
"INITIAL", "INTERRUPTED", null, null, OffsetDateTime.now(), OffsetDateTime.now());
|
||||
var next = new AgentRunService.RunView(UUID.randomUUID(), project, previous.modelConfigId(),
|
||||
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
|
||||
var store = mock(AgentRunStore.class);
|
||||
when(store.latest(project)).thenReturn(previous);
|
||||
when(store.create(project, "INITIAL", null)).thenReturn(next);
|
||||
var service = schedulingService(mock(ExecutorService.class), store);
|
||||
transaction(() -> {
|
||||
invokeMethod(service, "afterCommit", previous.id(), (Runnable) () -> {});
|
||||
assertThat(service.startMaterialCheck(project, UUID.randomUUID())).isEqualTo(next);
|
||||
}, false);
|
||||
}
|
||||
|
||||
private static void transaction(Runnable action, boolean commit) {
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
int status = TransactionSynchronization.STATUS_ROLLED_BACK;
|
||||
try {
|
||||
action.run();
|
||||
if (commit) {
|
||||
status = TransactionSynchronization.STATUS_COMMITTED;
|
||||
TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
|
||||
}
|
||||
} finally {
|
||||
for (var sync : TransactionSynchronizationManager.getSynchronizations()) sync.afterCompletion(status);
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络故障和服务端错误允许重连,参数错误保持原始失败。
|
||||
*/
|
||||
@Test
|
||||
void shouldRetryOnlyRecoverableModelFailures() {
|
||||
assertThat(AgentExecutionService.MAX_MODEL_RECONNECTS).isEqualTo(5);
|
||||
assertThat(AgentExecutionService.isRetryableModelFailure(
|
||||
new RuntimeException(new HttpTransportException("disconnected")))).isTrue();
|
||||
assertThat(AgentExecutionService.isRetryableModelFailure(
|
||||
new HttpTransportException("unavailable", 503, ""))).isTrue();
|
||||
assertThat(AgentExecutionService.isRetryableModelFailure(
|
||||
new HttpTransportException("invalid request", 400, ""))).isFalse();
|
||||
assertThat(AgentExecutionService.isRetryableModelFailure(
|
||||
new IllegalArgumentException("invalid prompt"))).isFalse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上下文压缩只按模型窗口 90% Token 触发。
|
||||
*/
|
||||
@Test
|
||||
void shouldCompactAtNinetyPercentTokensOnly() {
|
||||
var config = AgentFactory.compactionFor(100_000, "summary");
|
||||
|
||||
assertThat(config.getTriggerTokens()).isEqualTo(90_000);
|
||||
assertThat(config.getTriggerMessages()).isZero();
|
||||
assertThat(config.getSummaryPrompt()).isEqualTo("summary");
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Agent 调用 Skill 时使用无来源后缀的名称,并完整保留仓库信息。
|
||||
*/
|
||||
@Test
|
||||
void shouldExposeCanonicalSkillIdWithoutLosingSkillInformation() {
|
||||
AgentSkill source = new AgentSkill(
|
||||
Map.of("name", "pdf", "description", "读取 PDF", "version", "1.0"),
|
||||
"使用说明",
|
||||
Map.of("references/guide.md", "参考内容"),
|
||||
"imported",
|
||||
Path.of("/skills/pdf"));
|
||||
|
||||
AgentSkill canonical = AgentFactory.canonicalSkill(source);
|
||||
|
||||
assertThat(canonical.getSkillId()).isEqualTo("pdf");
|
||||
assertThat(canonical.getMetadata()).isEqualTo(source.getMetadata());
|
||||
assertThat(canonical.getSkillContent()).isEqualTo(source.getSkillContent());
|
||||
assertThat(canonical.getResources()).isEqualTo(source.getResources());
|
||||
assertThat(canonical.getSource()).isEqualTo("imported");
|
||||
assertThat(canonical.getOriginDir()).isEqualTo(source.getOriginDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型输出年份数组时可归一化为前端和确认接口使用的规划年数。
|
||||
*/
|
||||
@Test
|
||||
void shouldNormalizePlanningYearArray() {
|
||||
ObjectNode plan = tools.jackson.databind.json.JsonMapper.builder().build().createObjectNode();
|
||||
plan.putArray("planningYears").add("2026").add("2027");
|
||||
|
||||
AgentOutputService.normalizePlanningYears(plan);
|
||||
|
||||
assertThat(plan.path("planningYears").asInt()).isEqualTo(2);
|
||||
assertThat(plan.path("planningPeriod").asText()).isEqualTo("2026-2027");
|
||||
}
|
||||
@Test
|
||||
void shouldBoundRepairsAndReleaseCapacityOnRollback() throws Exception {
|
||||
var projectId = UUID.randomUUID();
|
||||
var output = mock(AgentOutputService.class);
|
||||
when(output.readMaterialCheck(projectId)).thenThrow(new java.io.IOException("broken JSON"));
|
||||
when(output.readProposedPlan(projectId)).thenThrow(new java.io.IOException("broken JSON"));
|
||||
var execution = mock(AgentExecutionService.class);
|
||||
var project = mock(ProjectService.ProjectView.class);
|
||||
when(project.id()).thenReturn(projectId);
|
||||
var run = new AgentRunService.RunView(UUID.randomUUID(), projectId, UUID.randomUUID(),
|
||||
"INITIAL", "RUNNING", null, null, OffsetDateTime.now(), null);
|
||||
var service = new AgentRunService(mock(AgentRunMapper.class), tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
execution, output, mock(AgentRunStore.class), mock(AgentEventService.class), mock(ProjectService.class),
|
||||
mock(ProjectFileService.class), mock(ArtifactService.class), mock(ExecutorService.class), mock(TransactionTemplate.class), 1);
|
||||
for (String method : java.util.List.of("readMaterialCheckWithRepair", "readProposedPlanWithRepair")) {
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod(
|
||||
service, method, project, run)).hasMessageContaining("连续修复失败");
|
||||
}
|
||||
verify(execution, org.mockito.Mockito.times(6)).execute(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
Runnable task = () -> { };
|
||||
org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task);
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> org.springframework.test.util.ReflectionTestUtils.invokeMethod(
|
||||
service, "afterCommit", UUID.randomUUID(), task)).hasMessageContaining("已达上限");
|
||||
var synchronization = TransactionSynchronizationManager.getSynchronizations().getFirst();
|
||||
synchronization.afterCompletion(org.springframework.transaction.support.TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
org.springframework.test.util.ReflectionTestUtils.invokeMethod(service, "afterCommit", UUID.randomUUID(), task);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunEntity;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigEntity;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentEventMapper;
|
||||
import tech.easyflow.manuagent.agent.runtime.AgentRunMapper;
|
||||
import tech.easyflow.manuagent.agent.model.ModelConfigMapper;
|
||||
|
||||
/**
|
||||
* 验证 Agent 执行热路径只读取判断运行状态所需的最小列。
|
||||
*/
|
||||
class AgentRunStoreQueryTest {
|
||||
|
||||
/**
|
||||
* {@code ensureRunning} 可能被每个 Agent 检查点调用,因此不得加载 JSONB 和错误详情等整行数据。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyStatusWhenCheckingRunningState() {
|
||||
AgentRunMapper runMapper = mock(AgentRunMapper.class);
|
||||
AgentRunEntity running = new AgentRunEntity();
|
||||
running.setStatus("RUNNING");
|
||||
when(runMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(running);
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
runMapper,
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
store.ensureRunning(UUID.randomUUID());
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(runMapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("status")
|
||||
.doesNotContain("pending_interrupt", "error_message", "trace_id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库允许在首次启动时没有模型,因此启动 Run 时应返回可操作的业务错误,
|
||||
* 不能把正常的“尚未配置”状态暴露为服务端技术异常。
|
||||
*/
|
||||
@Test
|
||||
void shouldReportMissingDefaultModelAsConfigurationConflict() {
|
||||
AgentRunMapper runMapper = mock(AgentRunMapper.class);
|
||||
when(runMapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
runMapper,
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
assertThatThrownBy(() -> store.create(UUID.randomUUID(), "INITIAL", null))
|
||||
.isInstanceOfSatisfying(ApiException.class, exception -> {
|
||||
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,
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 ID 强制读取或运行状态检查遇到不存在的 Run 时,不新增 404 或“已中断”业务语义。
|
||||
*/
|
||||
@Test
|
||||
void shouldKeepMissingRequiredRunAsUnexpectedTechnicalFailure() {
|
||||
AgentRunStore store = new AgentRunStore(
|
||||
mock(AgentRunMapper.class),
|
||||
mock(AgentEventMapper.class),
|
||||
mock(ModelConfigMapper.class),
|
||||
tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
UUID runId = UUID.randomUUID();
|
||||
|
||||
assertThatThrownBy(() -> store.require(runId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(ApiException.class);
|
||||
assertThatThrownBy(() -> store.ensureRunning(runId))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.isNotInstanceOf(AgentExecutionService.RunInterruptedException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import io.agentscope.core.ReActAgent;
|
||||
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
|
||||
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
|
||||
import io.agentscope.core.agui.event.AguiEvent;
|
||||
import io.agentscope.core.agui.model.AguiMessage;
|
||||
import io.agentscope.core.agui.model.RunAgentInput;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.model.ChatResponse;
|
||||
import io.agentscope.core.model.Model;
|
||||
import io.agentscope.core.model.transport.HttpTransportException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
class AgentRuntimeMiddlewareTest {
|
||||
@Test
|
||||
void shouldKeepOriginalFailureAcrossRealAguiConversionAndRefreshLanguage() {
|
||||
var model = mock(Model.class);
|
||||
when(model.getModelName()).thenReturn("test-model");
|
||||
var failure = new HttpTransportException("unavailable", 503, "");
|
||||
var inputs = new ArrayList<List<Msg>>();
|
||||
when(model.stream(any(), any(), any())).thenAnswer(call -> {
|
||||
inputs.add(call.getArgument(0));
|
||||
return inputs.size() == 1 ? Flux.error(failure) : Flux.just(ChatResponse.builder()
|
||||
.content(List.of(TextBlock.builder().text("已完成").build())).build());
|
||||
});
|
||||
var middleware = new AgentRuntimeMiddleware();
|
||||
var agent = ReActAgent.builder().name("test-agent").model(model).middleware(middleware).build();
|
||||
var adapter = new AguiAgentAdapter(agent, AguiAdapterConfig.builder().build());
|
||||
var input = RunAgentInput.builder().threadId("language-test").runId("run-1")
|
||||
.messages(List.of(AguiMessage.userMessage("m1", "Please inspect the file."))).build();
|
||||
var events = adapter.run(input).collectList().block();
|
||||
assertThat(events).anyMatch(AguiEvent.RunError.class::isInstance);
|
||||
assertThat(middleware.modelFailure()).isSameAs(failure);
|
||||
adapter.run(input).blockLast();
|
||||
assertThat(middleware.modelFailure()).isNull();
|
||||
assertThat(inputs).hasSize(2);
|
||||
for (var messages : inputs) {
|
||||
assertThat(messages.getLast().getTextContent()).contains("简体中文", "英文 Skill", "专有名词");
|
||||
assertThat(messages.stream().filter(msg -> msg.getTextContent().contains("<system-reminder>"))).hasSize(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.agentscope.core.state.JsonFileAgentStateStore;
|
||||
import io.agentscope.core.state.AgentState;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.harness.agent.IsolationScope;
|
||||
import io.agentscope.harness.agent.sandbox.SandboxIsolationKey;
|
||||
import io.agentscope.harness.agent.sandbox.SessionSandboxStateStore;
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClient;
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
|
||||
import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
class AgentStateFilesTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test
|
||||
void shouldPreserveLegacySessionAndReloadFromPersistentRoot() throws Exception {
|
||||
UUID projectId = UUID.randomUUID();
|
||||
String session = "project-" + projectId;
|
||||
Path legacy = root.resolve("legacy");
|
||||
Path source = legacy.resolve("smart-factory-agent/__anon__").resolve(session);
|
||||
Files.createDirectories(source);
|
||||
Files.writeString(source.resolve("memory_messages.jsonl"), "历史记忆\n");
|
||||
var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent"));
|
||||
oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build());
|
||||
Path target = AgentStateFiles.prepare(root.resolve("data"), legacy, projectId);
|
||||
var restored = new JsonFileAgentStateStore(target);
|
||||
assertThat(restored.get(null, session, "agent_state", AgentState.class)).isPresent();
|
||||
assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl")))
|
||||
.isEqualTo("历史记忆\n");
|
||||
assertThat(source.resolve("agent_state.json")).exists();
|
||||
Files.writeString(source.resolve("memory_messages.jsonl"), "旧进程后来修改的内容");
|
||||
AgentStateFiles.prepare(root.resolve("data"), legacy, projectId);
|
||||
assertThat(Files.readString(target.resolve("__anon__").resolve(session).resolve("memory_messages.jsonl")))
|
||||
.isEqualTo("历史记忆\n");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {false, true})
|
||||
void shouldRestoreSandboxSnapshotAndBackfillAlreadyMigratedSessions(boolean migratedBefore) throws Exception {
|
||||
UUID projectId = UUID.randomUUID();
|
||||
String session = "project-" + projectId;
|
||||
Path legacy = root.resolve("legacy");
|
||||
Path data = root.resolve("data");
|
||||
var oldStore = new JsonFileAgentStateStore(legacy.resolve("smart-factory-agent"));
|
||||
var key = SandboxIsolationKey.resolve(IsolationScope.SESSION,
|
||||
RuntimeContext.builder().sessionId(session).build(), "smart-factory-agent").orElseThrow();
|
||||
var oldSandbox = new SessionSandboxStateStore(oldStore, "smart-factory-agent");
|
||||
oldStore.save(null, session, "agent_state", AgentState.builder().sessionId(session).build());
|
||||
if (migratedBefore) {
|
||||
// 模拟上一版本已经复制会话,但尚未复制沙箱独立状态槽。
|
||||
AgentStateFiles.prepare(data, legacy, projectId);
|
||||
}
|
||||
|
||||
var client = new DockerSandboxClient();
|
||||
var state = new DockerSandboxState();
|
||||
state.setSessionId(UUID.randomUUID().toString());
|
||||
state.setImage("agent-sandbox:0.1");
|
||||
state.setSnapshot(new LocalSnapshotSpec(data.resolve("sandbox-snapshots")).build(state.getSessionId()));
|
||||
byte[] archive = "snapshot-fixture".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
state.getSnapshot().persist(new ByteArrayInputStream(archive));
|
||||
String saved = client.serializeState(state);
|
||||
oldSandbox.save(key, saved);
|
||||
|
||||
Path target = AgentStateFiles.prepare(data, legacy, projectId);
|
||||
var restoredStore = new JsonFileAgentStateStore(target);
|
||||
var restoredSandbox = new SessionSandboxStateStore(restoredStore, "smart-factory-agent");
|
||||
assertThat(restoredStore.get(null, session, "agent_state", AgentState.class)).isPresent();
|
||||
assertThat(restoredSandbox.load(key)).contains(saved);
|
||||
try (var input = client.deserializeState(restoredSandbox.load(key).orElseThrow()).getSnapshot().restore()) {
|
||||
assertThat(input.readAllBytes()).isEqualTo(archive);
|
||||
}
|
||||
assertThat(oldSandbox.load(key)).contains(saved);
|
||||
|
||||
restoredSandbox.save(key, "newer-state");
|
||||
AgentStateFiles.prepare(data, legacy, projectId);
|
||||
assertThat(restoredSandbox.load(key)).contains("newer-state");
|
||||
restoredSandbox.delete(key);
|
||||
AgentStateFiles.prepare(data, legacy, projectId);
|
||||
assertThat(restoredSandbox.load(key)).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
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 tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.model.ToolSchema;
|
||||
import io.agentscope.core.tool.Toolkit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.UUID;
|
||||
import java.io.IOException;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import tech.easyflow.manuagent.agent.project.ProjectFileService;
|
||||
|
||||
/**
|
||||
* 验证文档视觉工具可以被 AgentScope 注册并暴露结构化参数。
|
||||
*/
|
||||
class DocumentViewToolTest {
|
||||
|
||||
/**
|
||||
* 验证多视图参数能够进入模型工具定义。
|
||||
*/
|
||||
@Test
|
||||
void shouldRegisterMultiViewSchema() {
|
||||
Toolkit toolkit = new Toolkit();
|
||||
|
||||
toolkit.registerTool(new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(),
|
||||
mock(ProjectFileService.class), UUID.randomUUID()));
|
||||
|
||||
ToolSchema schema = toolkit.getToolSchemas().getFirst();
|
||||
assertThat(schema.getName()).isEqualTo("document_view");
|
||||
assertThat(schema.getParameters().toString()).contains("views", "path", "page", "sheet", "range");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveLargePreviewBytesAndRejectIncompleteImages() throws IOException {
|
||||
UUID projectId = UUID.randomUUID();
|
||||
String path = "work/tmp/document-view/test/render-1.png";
|
||||
byte[] bytes = new byte[700_000];
|
||||
new java.util.Random(1).nextBytes(bytes);
|
||||
ProjectFileService files = mock(ProjectFileService.class);
|
||||
when(files.preview(projectId, path)).thenReturn(
|
||||
new ProjectFileService.Preview("image/png", new ByteArrayResource(bytes)));
|
||||
var tool = new DocumentViewTool(tools.jackson.databind.json.JsonMapper.builder().build(), files, projectId);
|
||||
assertThat(tool.readPreview(path, bytes.length)).isEqualTo(bytes);
|
||||
assertThatThrownBy(() -> tool.readPreview(path, bytes.length + 1)).isInstanceOf(IOException.class);
|
||||
assertThatThrownBy(() -> tool.readPreview(path, 5 * 1024 * 1024)).isInstanceOf(IOException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import io.agentscope.core.agent.RuntimeContext;
|
||||
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
|
||||
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 验证文件读取工具不会静默丢失后续内容。
|
||||
*/
|
||||
class PagedReadFileToolTest {
|
||||
|
||||
/**
|
||||
* 沙箱返回分页元数据时,应将下一页位置明确告知 Agent。
|
||||
*/
|
||||
@Test
|
||||
void shouldTellAgentHowToContinueReading() {
|
||||
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
|
||||
String content = Base64.getEncoder().encodeToString("第一行\n第二行".getBytes(StandardCharsets.UTF_8));
|
||||
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
|
||||
.thenReturn(new ExecuteResponse(
|
||||
"{\"ok\":true,\"truncated\":true,\"nextOffset\":2,\"returnedLines\":2,\"lineTooLong\":false}\n"
|
||||
+ content,
|
||||
0,
|
||||
false));
|
||||
PagedReadFileTool tool = new PagedReadFileTool(
|
||||
filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
String result = tool.readFile(RuntimeContext.empty(), "/workspace/work/report.txt", 0, 2);
|
||||
|
||||
assertThat(result).contains("第一行\n第二行");
|
||||
assertThat(result).contains("内容未读完");
|
||||
assertThat(result).contains("offset=2");
|
||||
}
|
||||
|
||||
/**
|
||||
* 沙箱自身发生截断时,应明确提示重试,不能把残缺内容当作完整结果。
|
||||
*/
|
||||
@Test
|
||||
void shouldExposeUnexpectedSandboxTruncation() {
|
||||
AbstractSandboxFilesystem filesystem = mock(AbstractSandboxFilesystem.class);
|
||||
when(filesystem.execute(any(RuntimeContext.class), anyString(), isNull()))
|
||||
.thenReturn(new ExecuteResponse(
|
||||
"{\"ok\":true,\"truncated\":true,\"nextOffset\":10,\"returnedLines\":10,\"lineTooLong\":false}\n",
|
||||
0,
|
||||
true));
|
||||
PagedReadFileTool tool = new PagedReadFileTool(
|
||||
filesystem, WorkspacePathNormalizer.of("/workspace"), tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
|
||||
String result = tool.readFile(RuntimeContext.empty(), "work/report.txt", 0, 10);
|
||||
|
||||
assertThat(result).contains("内容未读完");
|
||||
assertThat(result).contains("offset=10");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState;
|
||||
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RecoverableDockerClientTest {
|
||||
@Test
|
||||
void shouldRecoverOnlyTheSdkOwnedCanonicalSessionContainer() {
|
||||
var client = new RecoverableDockerClient("agent-sandbox:0.1");
|
||||
var state = new DockerSandboxState();
|
||||
state.setWorkspaceSpec(new WorkspaceSpec());
|
||||
state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f");
|
||||
state.setContainerName("agentscope-sandbox-" + state.getSessionId());
|
||||
state.setContainerId("obsolete-id");
|
||||
client.resume(state);
|
||||
assertThat(state.getContainerId()).isEqualTo(state.getContainerName());
|
||||
|
||||
state.setContainerOwned(false);
|
||||
state.setContainerId("external-id");
|
||||
client.resume(state);
|
||||
assertThat(state.getContainerId()).isEqualTo("external-id");
|
||||
|
||||
state.setContainerOwned(true);
|
||||
state.setContainerName("other-project");
|
||||
client.resume(state);
|
||||
assertThat(state.getContainerId()).isEqualTo("external-id");
|
||||
|
||||
state.setSessionId("untrusted-name");
|
||||
state.setContainerName("agentscope-sandbox-untrusted-name");
|
||||
client.resume(state);
|
||||
assertThat(state.getContainerId()).isEqualTo("external-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMigrateOnlyOwnedLegacyImagesToTheConfiguredImage() {
|
||||
var client = new RecoverableDockerClient("registry.example/agent-sandbox:0.1");
|
||||
var state = new DockerSandboxState();
|
||||
var workspace = new WorkspaceSpec();
|
||||
state.setWorkspaceSpec(workspace);
|
||||
state.setSessionId("08726648-85b1-42c0-a9c8-161e6b44150f");
|
||||
for (String tag : new String[]{"0.1.0", "pre-font-fix"}) {
|
||||
state.setImage("smart-factory-agent-runtime:" + tag);
|
||||
var sandbox = client.resume(state);
|
||||
assertThat(sandbox.getState()).isSameAs(state);
|
||||
assertThat(state.getImage()).isEqualTo("registry.example/agent-sandbox:0.1");
|
||||
assertThat(state.getWorkspaceSpec()).isSameAs(workspace);
|
||||
assertThat(state.getSessionId()).isEqualTo("08726648-85b1-42c0-a9c8-161e6b44150f");
|
||||
}
|
||||
|
||||
state.setImage("custom-sandbox:latest");
|
||||
client.resume(state);
|
||||
assertThat(state.getImage()).isEqualTo("custom-sandbox:latest");
|
||||
|
||||
state.setContainerOwned(false);
|
||||
state.setImage("smart-factory-agent-runtime:0.1.0");
|
||||
client.resume(state);
|
||||
assertThat(state.getImage()).isEqualTo("smart-factory-agent-runtime:0.1.0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tech.easyflow.manuagent.agent.runtime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
|
||||
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class SandboxToolsTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test
|
||||
void shouldExecuteRealEditsAndPreserveShellFailures() throws Exception {
|
||||
var filesystem = mock(AbstractSandboxFilesystem.class);
|
||||
when(filesystem.execute(any(), anyString(), anyInt())).thenAnswer(call -> {
|
||||
Process process = new ProcessBuilder("bash", "-c", call.getArgument(1, String.class))
|
||||
.directory(root.toFile()).redirectErrorStream(true).start();
|
||||
String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
return new ExecuteResponse(output, process.waitFor(), false);
|
||||
});
|
||||
var sandboxTools = new SandboxTools(filesystem, tools.jackson.databind.json.JsonMapper.builder().build());
|
||||
Path file = root.resolve("含 ' 引号.txt");
|
||||
Files.writeString(file, "中文原文\n重复 重复\n");
|
||||
var edited = sandboxTools.editFile(null, file.getFileName().toString(), "中文原文", "引号'、反引号`、$(false)\n第二行", false);
|
||||
assertThat(edited.get("success")).isEqualTo(true);
|
||||
assertThat(Files.readString(file)).startsWith("引号'、反引号`、$(false)\n第二行");
|
||||
String before = Files.readString(file);
|
||||
assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", false).get("success")).isEqualTo(false);
|
||||
assertThat(Files.readString(file)).isEqualTo(before);
|
||||
assertThat(sandboxTools.editFile(null, file.getFileName().toString(), "重复", "替换", true).get("replacements")).isEqualTo(2);
|
||||
assertThat(sandboxTools.editFile(null, "../outside", "x", "y", false).get("success")).isEqualTo(false);
|
||||
Files.createSymbolicLink(root.resolve("outside"), root.getParent());
|
||||
assertThat(sandboxTools.editFile(null, "outside/no-file", "x", "y", false).get("success")).isEqualTo(false);
|
||||
|
||||
var failed = sandboxTools.execute(null, "false | tail -1\ntouch should-not-exist", null, 5);
|
||||
assertThat(failed.get("success")).isEqualTo(false);
|
||||
assertThat(failed.get("exitCode")).isEqualTo(1);
|
||||
assertThat(root.resolve("should-not-exist")).doesNotExist();
|
||||
assertThat(sandboxTools.execute(null, "printf '正常'", null, 5).get("output")).isEqualTo("正常");
|
||||
// 通过 SDK 的反射注册和调用链验证工具覆盖,避免只测 Java 方法而漏掉协议接入。
|
||||
var toolkit = new io.agentscope.core.tool.Toolkit();
|
||||
toolkit.registerTool(sandboxTools);
|
||||
var input = java.util.Map.<String, Object>of("path", file.getFileName().toString(), "old_string", "第二行", "new_string", "SDK 编辑成功");
|
||||
var call = io.agentscope.core.message.ToolUseBlock.builder().id("edit-check").name("edit_file")
|
||||
.input(input).content(tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString(input)).build();
|
||||
var result = toolkit.callTool(io.agentscope.core.tool.ToolCallParam.builder().toolUseBlock(call).build()).block();
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(((io.agentscope.core.message.TextBlock) result.getOutput().getFirst()).getText()).contains("\"success\":true");
|
||||
assertThat(Files.readString(file)).contains("SDK 编辑成功");
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tech.easyflow.manuagent.agent.skill;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
/**
|
||||
* 验证 Skill ZIP 的目录识别与无关文件过滤。
|
||||
*/
|
||||
class SkillArchiveTest {
|
||||
|
||||
@TempDir
|
||||
private Path temporaryDirectory;
|
||||
|
||||
/**
|
||||
* 验证 macOS 元数据与 Python 缓存不会阻止有效 Skill 导入。
|
||||
*
|
||||
* @throws Exception ZIP 构造或解压失败时抛出
|
||||
*/
|
||||
@Test
|
||||
void shouldLocateSkillAndIgnoreGeneratedFiles() throws Exception {
|
||||
Map<String, byte[]> files = new LinkedHashMap<>();
|
||||
files.put("sample/SKILL.md", "---\nname: sample\ndescription: 示例 Skill\n---\n规则".getBytes(StandardCharsets.UTF_8));
|
||||
files.put("sample/scripts/run.py", "print('ok')".getBytes(StandardCharsets.UTF_8));
|
||||
files.put("__MACOSX/._sample", new byte[]{0, 1});
|
||||
files.put("sample/.DS_Store", new byte[]{0, 1});
|
||||
files.put("sample/scripts/__pycache__/run.pyc", new byte[]{0, 1});
|
||||
|
||||
MockMultipartFile archive = new MockMultipartFile(
|
||||
"file", "sample.zip", "application/zip", zip(files));
|
||||
Path extracted = temporaryDirectory.resolve("extracted");
|
||||
Files.createDirectories(extracted);
|
||||
|
||||
SkillService.unzip(archive, extracted);
|
||||
Path skillRoot = SkillService.locateSkillRoot(extracted);
|
||||
SkillPackageReader.SkillPackage skillPackage = new SkillPackageReader().read(skillRoot, "imported");
|
||||
|
||||
assertThat(skillRoot.getFileName().toString()).isEqualTo("sample");
|
||||
assertThat(skillPackage.skill().getName()).isEqualTo("sample");
|
||||
assertThat(skillPackage.skill().getResourcePaths()).containsExactly("scripts/run.py");
|
||||
assertThat(extracted.resolve("__MACOSX")).doesNotExist();
|
||||
assertThat(skillRoot.resolve("scripts/__pycache__")).doesNotExist();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造测试 ZIP。
|
||||
*
|
||||
* @param files ZIP 内文件
|
||||
* @return ZIP 字节
|
||||
* @throws Exception ZIP 写入失败时抛出
|
||||
*/
|
||||
private byte[] zip(Map<String, byte[]> files) throws Exception {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
|
||||
for (Map.Entry<String, byte[]> file : files.entrySet()) {
|
||||
zip.putNextEntry(new ZipEntry(file.getKey()));
|
||||
zip.write(file.getValue());
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user