Compare commits
9 Commits
develop
...
588e810c51
| Author | SHA1 | Date | |
|---|---|---|---|
| 588e810c51 | |||
| 30904f1503 | |||
| 885811a691 | |||
| b13280cf1f | |||
| 611119dea0 | |||
| 7a38518811 | |||
| 1870ac4028 | |||
| 2a383ef3f2 | |||
| 34ff62d317 |
@@ -11,6 +11,7 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
@@ -151,6 +152,19 @@ public class FileStorageManager implements FileStorageService {
|
|||||||
return serviceForHandle(handle).readRecoverable(handle);
|
return serviceForHandle(handle).readRecoverable(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用当前后端解析服务端可信文件引用。
|
||||||
|
*
|
||||||
|
* @param reference 文件 URL 或其他后端可识别引用
|
||||||
|
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
||||||
|
* @throws IOException 文件记录无法安全解析时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
||||||
|
throws IOException {
|
||||||
|
return currentService().resolveTrustedFile(reference);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 严格按句柄中的后端精确删除物理对象。
|
* 严格按句柄中的后端精确删除物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EasyFlow 文件存储统一接口。
|
* EasyFlow 文件存储统一接口。
|
||||||
@@ -105,6 +106,21 @@ public interface FileStorageService {
|
|||||||
throw unsupportedRecoverableOperation("readRecoverable");
|
throw unsupportedRecoverableOperation("readRecoverable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将服务端可信文件引用解析为物理读取句柄。
|
||||||
|
*
|
||||||
|
* <p>实现必须以服务端持久化记录或存储平台配置为信任来源,并要求外部引用与可信来源
|
||||||
|
* 精确匹配;不得仅根据客户端传入的 URL、路径或 locator 构造句柄。</p>
|
||||||
|
*
|
||||||
|
* @param reference 文件 URL 或其他后端可识别引用
|
||||||
|
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
||||||
|
* @throws IOException 文件记录损坏或存储配置不兼容时抛出
|
||||||
|
*/
|
||||||
|
default Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
||||||
|
throws IOException {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精确且幂等地删除句柄对应的物理对象。
|
* 精确且幂等地删除句柄对应的物理对象。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.io.*;
|
|||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
|
* 基于 x-file-storage 的 EasyFlow 文件存储实现。
|
||||||
@@ -268,6 +269,47 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 x-file-storage 文件记录或服务端平台配置恢复可信物理读取句柄。
|
||||||
|
*
|
||||||
|
* <p>优先使用 recorder 的精确记录。记录不存在时,仅允许与已配置平台 domain、basePath
|
||||||
|
* 及重建后的完整 URL 完全一致的引用。其他 URL 返回空,由上层继续执行公网地址安全校验。</p>
|
||||||
|
*
|
||||||
|
* @param reference 文件 URL
|
||||||
|
* @return 可信文件的物理读取句柄;引用无法确认时为空
|
||||||
|
* @throws IOException 文件记录损坏或平台配置不兼容时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference)
|
||||||
|
throws IOException {
|
||||||
|
if (!StringUtils.hasText(reference)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
FileInfo fileInfo = null;
|
||||||
|
try {
|
||||||
|
fileInfo = fileStorageService.getFileInfoByUrl(reference);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
// 存储平台配置本身仍可提供精确可信边界,记录器异常不应阻断内部对象读取。
|
||||||
|
LOG.warn("查询 x-file-storage 文件记录失败,继续按服务端存储配置识别,reference={}",
|
||||||
|
reference, exception);
|
||||||
|
}
|
||||||
|
if (fileInfo != null) {
|
||||||
|
if (!reference.equals(fileInfo.getUrl())) {
|
||||||
|
throw new IOException("x-file-storage 文件记录 URL 与请求引用不一致");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
||||||
|
FileStorage storage = requireStorage(handle);
|
||||||
|
requirePersistedBasePathSupport(storage, handle);
|
||||||
|
verifyRecordedLocation(fileInfo, handle);
|
||||||
|
return Optional.of(handle);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("x-file-storage 文件记录无法恢复为安全读取位置", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolveConfiguredStorageReference(reference);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
* 直接调用句柄指定平台的物理删除与存在检查,绕过依赖 URL 记录的聚合删除路径。
|
||||||
*
|
*
|
||||||
@@ -391,6 +433,108 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 recorder 中的物理定位字段可由恢复句柄无损重建。
|
||||||
|
*
|
||||||
|
* @param fileInfo 服务端文件记录
|
||||||
|
* @param handle 恢复出的物理读取句柄
|
||||||
|
*/
|
||||||
|
private void verifyRecordedLocation(FileInfo fileInfo, FileStorageWriteHandle handle) {
|
||||||
|
String actualBasePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
||||||
|
String actualPath = fileInfo.getPath() == null ? "" : fileInfo.getPath();
|
||||||
|
if (!handle.getPlatform().equals(fileInfo.getPlatform())
|
||||||
|
|| !handle.getBasePath().equals(actualBasePath)
|
||||||
|
|| !physicalPath(handle).equals(actualPath)
|
||||||
|
|| !handle.getFilename().equals(fileInfo.getFilename())) {
|
||||||
|
throw new IllegalStateException("x-file-storage 文件记录包含非规范物理位置");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 FileInfo 的物理定位字段构造严格校验的读取句柄。
|
||||||
|
*
|
||||||
|
* @param fileInfo 服务端文件信息
|
||||||
|
* @return 可信物理读取句柄
|
||||||
|
*/
|
||||||
|
private FileStorageWriteHandle handleFromFileInfo(FileInfo fileInfo) {
|
||||||
|
String basePath = fileInfo.getBasePath() == null ? "" : fileInfo.getBasePath();
|
||||||
|
return new FileStorageWriteHandle(
|
||||||
|
RECOVERABLE_BACKEND,
|
||||||
|
fileInfo.getPlatform(),
|
||||||
|
basePath,
|
||||||
|
recordedRelativePath(basePath, fileInfo.getPath()),
|
||||||
|
fileInfo.getFilename());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按服务端配置的平台 domain 与 basePath 识别内部存储 URL。
|
||||||
|
*
|
||||||
|
* <p>解析后会再次通过平台自身的 getFileKey 重建完整 URL 并进行精确比较,避免仅凭
|
||||||
|
* host 或字符串前缀放行其他私网目标。</p>
|
||||||
|
*
|
||||||
|
* @param reference 待识别 URL
|
||||||
|
* @return 精确匹配配置的读取句柄;不匹配任何平台时为空
|
||||||
|
* @throws IOException 匹配平台前缀但路径无法安全恢复时抛出
|
||||||
|
*/
|
||||||
|
private Optional<FileStorageWriteHandle> resolveConfiguredStorageReference(
|
||||||
|
String reference) throws IOException {
|
||||||
|
if (fileStorageService.getFileStorageList() == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
for (FileStorage storage : fileStorageService.getFileStorageList()) {
|
||||||
|
String domain = readDomainBestEffort(storage);
|
||||||
|
if (!StringUtils.hasText(domain)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String basePath = readRequiredBasePath(storage);
|
||||||
|
String prefix = domain + basePath;
|
||||||
|
if (!reference.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String remainder = reference.substring(prefix.length());
|
||||||
|
int filenameIndex = remainder.lastIndexOf('/') + 1;
|
||||||
|
FileInfo fileInfo = new FileInfo()
|
||||||
|
.setUrl(reference)
|
||||||
|
.setPlatform(storage.getPlatform())
|
||||||
|
.setBasePath(basePath)
|
||||||
|
.setPath(remainder.substring(0, filenameIndex))
|
||||||
|
.setFilename(remainder.substring(filenameIndex));
|
||||||
|
FileStorageWriteHandle handle = handleFromFileInfo(fileInfo);
|
||||||
|
requirePersistedBasePathSupport(storage, handle);
|
||||||
|
verifyRecordedLocation(fileInfo, handle);
|
||||||
|
if (!reference.equals(deriveUrlBestEffort(storage, toFileInfo(handle)))) {
|
||||||
|
throw new IllegalArgumentException("重建 URL 与请求引用不一致");
|
||||||
|
}
|
||||||
|
return Optional.of(handle);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new IOException("服务端存储 URL 无法恢复为安全读取位置", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 recorder 保存的 x-file-storage 物理路径还原为句柄相对路径。
|
||||||
|
*
|
||||||
|
* @param basePath 平台基础路径
|
||||||
|
* @param recordedPath recorder 中保存的物理目录
|
||||||
|
* @return 不带前导斜杠的相对目录
|
||||||
|
*/
|
||||||
|
private String recordedRelativePath(String basePath, String recordedPath) {
|
||||||
|
String path = recordedPath == null ? "" : recordedPath;
|
||||||
|
if (basePath.isEmpty() || basePath.endsWith("/")) {
|
||||||
|
if (path.startsWith("/")) {
|
||||||
|
throw new IllegalArgumentException("文件记录路径与平台基础路径格式不一致");
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
throw new IllegalArgumentException("文件记录路径缺少必要的前导斜杠");
|
||||||
|
}
|
||||||
|
return path.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造仅包含精确物理定位字段的 FileInfo。
|
* 构造仅包含精确物理定位字段的 FileInfo。
|
||||||
*
|
*
|
||||||
@@ -487,16 +631,26 @@ public class XFIleStorageServiceImpl implements FileStorageService {
|
|||||||
* @return 可推导 URL;平台不支持时返回 null
|
* @return 可推导 URL;平台不支持时返回 null
|
||||||
*/
|
*/
|
||||||
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
|
private String deriveUrlBestEffort(FileStorage storage, FileInfo fileInfo) {
|
||||||
|
String domain = readDomainBestEffort(storage);
|
||||||
|
if (domain == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return domain + storage.getFileKey(fileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用平台公开的 getDomain 方法读取文件访问域名。
|
||||||
|
*
|
||||||
|
* @param storage 具体平台存储
|
||||||
|
* @return 平台访问域名;平台不支持时返回 null
|
||||||
|
*/
|
||||||
|
private String readDomainBestEffort(FileStorage storage) {
|
||||||
try {
|
try {
|
||||||
Method method = storage.getClass().getMethod("getDomain");
|
Method method = storage.getClass().getMethod("getDomain");
|
||||||
if (!String.class.equals(method.getReturnType())) {
|
if (!String.class.equals(method.getReturnType())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String domain = (String) method.invoke(storage);
|
return (String) method.invoke(storage);
|
||||||
if (domain == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return domain + storage.getFileKey(fileInfo);
|
|
||||||
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
|
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException | RuntimeException exception) {
|
||||||
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
|
LOG.debug("当前 x-file-storage 平台无法推导 recorder URL: {}", storage.getClass().getName());
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
@@ -49,6 +50,27 @@ public class FileStorageManagerTest {
|
|||||||
assertFalse(exists);
|
assertFalse(exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证服务端文件记录解析使用当前配置的具体存储后端。
|
||||||
|
*
|
||||||
|
* @throws IOException 文件记录解析失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void recordedFileResolutionUsesCurrentBackend() throws IOException {
|
||||||
|
RecordingStorage local = new RecordingStorage("local");
|
||||||
|
RecordingStorage xFile = new RecordingStorage("xFileStorage");
|
||||||
|
FileStorageManager manager = new FileStorageManager(
|
||||||
|
() -> "xFileStorage",
|
||||||
|
backend -> Map.of("local", local, "xFileStorage", xFile).get(backend));
|
||||||
|
|
||||||
|
Optional<FileStorageWriteHandle> resolved = manager.resolveTrustedFile(
|
||||||
|
"http://127.0.0.1:39000/easyflow/attachment/demo.pdf");
|
||||||
|
|
||||||
|
assertSame(xFile.recordedHandle, resolved.orElseThrow());
|
||||||
|
assertEquals(1, xFile.resolveCalls);
|
||||||
|
assertEquals(0, local.resolveCalls);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可记录可恢复调用的存储测试替身。
|
* 可记录可恢复调用的存储测试替身。
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +79,8 @@ public class FileStorageManagerTest {
|
|||||||
private final String backend;
|
private final String backend;
|
||||||
/** 固定结果。 */
|
/** 固定结果。 */
|
||||||
private final FileStorageWriteResult result;
|
private final FileStorageWriteResult result;
|
||||||
|
/** 固定服务端文件记录句柄。 */
|
||||||
|
private final FileStorageWriteHandle recordedHandle;
|
||||||
/** 固定可恢复读取流。 */
|
/** 固定可恢复读取流。 */
|
||||||
private final InputStream recoverableInput = InputStream.nullInputStream();
|
private final InputStream recoverableInput = InputStream.nullInputStream();
|
||||||
/** prepare 调用次数。 */
|
/** prepare 调用次数。 */
|
||||||
@@ -69,6 +93,8 @@ public class FileStorageManagerTest {
|
|||||||
private int deleteCalls;
|
private int deleteCalls;
|
||||||
/** exists 调用次数。 */
|
/** exists 调用次数。 */
|
||||||
private int existsCalls;
|
private int existsCalls;
|
||||||
|
/** 服务端文件记录解析调用次数。 */
|
||||||
|
private int resolveCalls;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建指定名称的存储替身。
|
* 创建指定名称的存储替身。
|
||||||
@@ -80,6 +106,8 @@ public class FileStorageManagerTest {
|
|||||||
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||||
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
|
backend, "", "/tmp/easyflow", "skill-content", "content.bin");
|
||||||
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
|
this.result = new FileStorageWriteResult("/files/content.bin", handle.encodeLocator());
|
||||||
|
this.recordedHandle = new FileStorageWriteHandle(
|
||||||
|
backend, "", "/tmp/easyflow", "attachment", "demo.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@@ -114,6 +142,13 @@ public class FileStorageManagerTest {
|
|||||||
return recoverableInput;
|
return recoverableInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
|
||||||
|
resolveCalls++;
|
||||||
|
return Optional.of(recordedHandle);
|
||||||
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@Override
|
@Override
|
||||||
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
public void deleteRecoverable(FileStorageWriteHandle handle) {
|
||||||
|
|||||||
@@ -212,6 +212,123 @@ public class XFIleStorageServiceImplTest {
|
|||||||
client.lastArgs.object());
|
client.lastArgs.object());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 recorder 登记的回环地址附件可恢复为可信句柄并通过 MinIO 客户端直读。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试替身配置或流读取失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void recordedLoopbackUrlUsesExactMinioObject() throws Exception {
|
||||||
|
byte[] content = "workflow-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
RecordingMinioClient client = new RecordingMinioClient(content);
|
||||||
|
MinioFileStorage platform = new MinioFileStorage();
|
||||||
|
platform.setPlatform("minio-main");
|
||||||
|
platform.setBucketName("easyflow");
|
||||||
|
platform.setBasePath("attachment");
|
||||||
|
platform.setDomain("http://127.0.0.1:39000/easyflow/");
|
||||||
|
platform.setClientFactory(new FixedMinioClientFactory(client));
|
||||||
|
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
|
||||||
|
+ "d6186b17-4ab7-4f99-9299-b19df7ff0a3b/投标文件否决(废标)违规事项汇总.pdf";
|
||||||
|
delegate.recordedFileInfo = new FileInfo()
|
||||||
|
.setUrl(fileUrl)
|
||||||
|
.setPlatform("minio-main")
|
||||||
|
.setBasePath("attachment")
|
||||||
|
.setPath("/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/")
|
||||||
|
.setFilename("投标文件否决(废标)违规事项汇总.pdf");
|
||||||
|
XFIleStorageServiceImpl service = createService(delegate);
|
||||||
|
|
||||||
|
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
|
||||||
|
byte[] actual;
|
||||||
|
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||||
|
actual = inputStream.readAllBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("attachment", handle.getBasePath());
|
||||||
|
assertEquals(
|
||||||
|
"1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/",
|
||||||
|
handle.getPath());
|
||||||
|
assertArrayEquals(content, actual);
|
||||||
|
assertEquals("easyflow", client.lastArgs.bucket());
|
||||||
|
assertEquals(
|
||||||
|
"attachment/1/2026/8/26/d6186b17-4ab7-4f99-9299-b19df7ff0a3b/"
|
||||||
|
+ "投标文件否决(废标)违规事项汇总.pdf",
|
||||||
|
client.lastArgs.object());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 recorder 没有记录时,服务端配置的存储 URL 仍可通过 MinIO 客户端直读。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试替身配置或流读取失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void configuredStorageUrlWithoutRecorderUsesExactMinioObject() throws Exception {
|
||||||
|
byte[] content = "configured-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
RecordingMinioClient client = new RecordingMinioClient(content);
|
||||||
|
MinioFileStorage platform = new MinioFileStorage();
|
||||||
|
platform.setPlatform("minio-main");
|
||||||
|
platform.setBucketName("easyflow");
|
||||||
|
platform.setBasePath("attachment");
|
||||||
|
platform.setDomain("http://127.0.0.1:39000/easyflow/");
|
||||||
|
platform.setClientFactory(new FixedMinioClientFactory(client));
|
||||||
|
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/"
|
||||||
|
+ "0f0db465-7fa0-46b1-9fae-4f5a8c85f881/投标文件否决(废标)违规事项汇总.pdf";
|
||||||
|
|
||||||
|
FileStorageWriteHandle handle = service.resolveTrustedFile(fileUrl).orElseThrow();
|
||||||
|
byte[] actual;
|
||||||
|
try (InputStream inputStream = service.readRecoverable(handle)) {
|
||||||
|
actual = inputStream.readAllBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertArrayEquals(content, actual);
|
||||||
|
assertEquals(
|
||||||
|
"attachment/1/2026/8/26/0f0db465-7fa0-46b1-9fae-4f5a8c85f881/"
|
||||||
|
+ "投标文件否决(废标)违规事项汇总.pdf",
|
||||||
|
client.lastArgs.object());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未配置为存储地址的回环 URL 不会被识别为可信附件。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试替身注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void unconfiguredLoopbackUrlIsNotResolved() throws Exception {
|
||||||
|
RecoverablePlatform platform = new RecoverablePlatform(
|
||||||
|
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
|
||||||
|
XFIleStorageServiceImpl service = createService(new RecoverableStorageService(platform));
|
||||||
|
|
||||||
|
assertTrue(service.resolveTrustedFile(
|
||||||
|
"http://127.0.0.1:39000/other/unconfigured.pdf").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 recorder 中非规范物理路径会失败关闭。
|
||||||
|
*
|
||||||
|
* @throws Exception 测试替身注入失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void corruptedRecordedLocationIsRejected() throws Exception {
|
||||||
|
RecoverablePlatform platform = new RecoverablePlatform(
|
||||||
|
"minio-main", "attachment", "http://127.0.0.1:39000/easyflow/");
|
||||||
|
RecoverableStorageService delegate = new RecoverableStorageService(platform);
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/demo.pdf";
|
||||||
|
delegate.recordedFileInfo = new FileInfo()
|
||||||
|
.setUrl(fileUrl)
|
||||||
|
.setPlatform("minio-main")
|
||||||
|
.setBasePath("attachment")
|
||||||
|
.setPath("missing-leading-slash/")
|
||||||
|
.setFilename("demo.pdf");
|
||||||
|
XFIleStorageServiceImpl service = createService(delegate);
|
||||||
|
|
||||||
|
IOException exception = assertThrows(
|
||||||
|
IOException.class,
|
||||||
|
() -> service.resolveTrustedFile(fileUrl));
|
||||||
|
|
||||||
|
assertTrue(exception.getMessage().contains("无法恢复"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
* 验证 recorder 完全缺失目标记录时,精确删除仍直接作用于物理平台并成功。
|
||||||
*
|
*
|
||||||
@@ -471,6 +588,8 @@ public class XFIleStorageServiceImplTest {
|
|||||||
private int recorderDeleteCalls;
|
private int recorderDeleteCalls;
|
||||||
/** recorder 删除是否抛出异常。 */
|
/** recorder 删除是否抛出异常。 */
|
||||||
private boolean recorderDeleteThrows;
|
private boolean recorderDeleteThrows;
|
||||||
|
/** recorder 返回的服务端文件记录。 */
|
||||||
|
private FileInfo recordedFileInfo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建聚合服务替身。
|
* 创建聚合服务替身。
|
||||||
@@ -479,10 +598,17 @@ public class XFIleStorageServiceImplTest {
|
|||||||
*/
|
*/
|
||||||
private RecoverableStorageService(FileStorage platform) {
|
private RecoverableStorageService(FileStorage platform) {
|
||||||
this.platform = platform;
|
this.platform = platform;
|
||||||
|
setFileStorageList(new java.util.concurrent.CopyOnWriteArrayList<>(
|
||||||
|
java.util.List.of(platform)));
|
||||||
setFileRecorder(new FileRecorder() {
|
setFileRecorder(new FileRecorder() {
|
||||||
@Override public boolean save(FileInfo fileInfo) { return true; }
|
@Override public boolean save(FileInfo fileInfo) { return true; }
|
||||||
@Override public void update(FileInfo fileInfo) { }
|
@Override public void update(FileInfo fileInfo) { }
|
||||||
@Override public FileInfo getByUrl(String url) { return null; }
|
@Override public FileInfo getByUrl(String url) {
|
||||||
|
return recordedFileInfo != null
|
||||||
|
&& url.equals(recordedFileInfo.getUrl())
|
||||||
|
? recordedFileInfo
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@Override public boolean delete(String url) {
|
@Override public boolean delete(String url) {
|
||||||
recorderDeleteCalls++;
|
recorderDeleteCalls++;
|
||||||
if (recorderDeleteThrows) {
|
if (recorderDeleteThrows) {
|
||||||
@@ -506,6 +632,15 @@ public class XFIleStorageServiceImplTest {
|
|||||||
return platform.getPlatform().equals(name) ? (T) platform : null;
|
return platform.getPlatform().equals(name) ? (T) platform : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public FileInfo getFileInfoByUrl(String url) {
|
||||||
|
return recordedFileInfo != null
|
||||||
|
&& url.equals(recordedFileInfo.getUrl())
|
||||||
|
? recordedFileInfo
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/** {@inheritDoc} */
|
/** {@inheritDoc} */
|
||||||
@Override
|
@Override
|
||||||
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
|
public org.dromara.x.file.storage.core.upload.UploadPretreatment of(Object file) {
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ public class AgentRunService {
|
|||||||
chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
|
chatContext.getExt().put(DOCUMENT_CONTEXT_TOKEN_ESTIMATE_EXT_KEY,
|
||||||
documentContext.tokenEstimate());
|
documentContext.tokenEstimate());
|
||||||
String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
|
String runtimePrompt = effectivePrompt(prompt, !boundDocuments.isEmpty(), !boundMedia.isEmpty());
|
||||||
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia);
|
AgentMessage userMessage = buildAgentMessage(runtimePrompt, boundMedia, documentContext);
|
||||||
threadPoolTaskExecutor.execute(() -> startRuntime(
|
threadPoolTaskExecutor.execute(() -> startRuntime(
|
||||||
agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
|
agent, userMessage, documentContext, account, requestId, traceId, runtimeSessionId,
|
||||||
assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
|
assistantCode, chatContext, runOutput, persistChatlog, runtimeSessionStore, lockHandle));
|
||||||
@@ -646,24 +646,6 @@ public class AgentRunService {
|
|||||||
return agentDocumentService.bindDraft(documentUploads);
|
return agentDocumentService.bindDraft(documentUploads);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将本轮文档正文追加到临时运行定义的系统提示词中。
|
|
||||||
*
|
|
||||||
* <p>正文只存在于本轮模型调用定义,不写入 chatlog 或 AgentScope 消息记忆。</p>
|
|
||||||
*
|
|
||||||
* @param bundle 临时运行时编译结果
|
|
||||||
* @param documentContext 本轮文档上下文
|
|
||||||
*/
|
|
||||||
private void appendDocumentContext(AgentRuntimeBundle bundle, AgentDocumentContext documentContext) {
|
|
||||||
if (bundle == null || bundle.getDefinition() == null
|
|
||||||
|| documentContext == null || documentContext.text().isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String current = bundle.getDefinition().getSystemPrompt();
|
|
||||||
bundle.getDefinition().setSystemPrompt(
|
|
||||||
(current == null ? "" : current) + documentContext.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为仅附件输入生成可持久化的最小用户意图。
|
* 为仅附件输入生成可持久化的最小用户意图。
|
||||||
*
|
*
|
||||||
@@ -1187,6 +1169,8 @@ public class AgentRunService {
|
|||||||
StringBuilder answer = new StringBuilder();
|
StringBuilder answer = new StringBuilder();
|
||||||
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
|
ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
|
||||||
LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
|
LegacyThinkingTagParser legacyThinkingTagParser = new LegacyThinkingTagParser();
|
||||||
|
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker =
|
||||||
|
new KnowledgeRetrievalStatusTracker();
|
||||||
// 注册 emit 服务
|
// 注册 emit 服务
|
||||||
registerEmitterCancellation(requestId, runOutput, chatContext, answer,
|
registerEmitterCancellation(requestId, runOutput, chatContext, answer,
|
||||||
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
||||||
@@ -1195,7 +1179,7 @@ public class AgentRunService {
|
|||||||
if (isAguiCancellationRequested(runOutput)) {
|
if (isAguiCancellationRequested(runOutput)) {
|
||||||
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
|
handleRuntimeEvent(cancellationEvent("用户已停止生成"), requestId, runOutput,
|
||||||
answer, assistantAccumulator, legacyThinkingTagParser,
|
answer, assistantAccumulator, legacyThinkingTagParser,
|
||||||
chatContext, finished, persistChatlog);
|
knowledgeRetrievalStatusTracker, chatContext, finished, persistChatlog);
|
||||||
if (lockHandle != null) {
|
if (lockHandle != null) {
|
||||||
releaseRunLockQuietly(lockHandle, requestId);
|
releaseRunLockQuietly(lockHandle, requestId);
|
||||||
}
|
}
|
||||||
@@ -1206,7 +1190,6 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId);
|
AgentRuntimeContext runtimeContext = buildAgentRuntimeContext(chatContext, traceId, runtimeSessionId);
|
||||||
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
|
AgentRuntimeBundle bundle = agentRuntimeCompiler.compile(agent, runtimeContext, !persistChatlog);
|
||||||
appendDocumentContext(bundle, documentContext);
|
|
||||||
AgentRuntime runtime = agentRuntimeFactory.create();
|
AgentRuntime runtime = agentRuntimeFactory.create();
|
||||||
// 会话初始化请求
|
// 会话初始化请求
|
||||||
AgentInitRequest request = new AgentInitRequest();
|
AgentInitRequest request = new AgentInitRequest();
|
||||||
@@ -1214,7 +1197,7 @@ public class AgentRunService {
|
|||||||
request.setAgentDefinition(bundle.getDefinition());
|
request.setAgentDefinition(bundle.getDefinition());
|
||||||
request.setRuntimeContext(runtimeContext);
|
request.setRuntimeContext(runtimeContext);
|
||||||
request.setToolInvokers(bundle.getToolInvokers());
|
request.setToolInvokers(bundle.getToolInvokers());
|
||||||
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
request.setKnowledgeRegistrations(bundle.getKnowledgeRegistrations());
|
||||||
request.setSessionStore(runtimeSessionStore);
|
request.setSessionStore(runtimeSessionStore);
|
||||||
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
||||||
request.getMetadata().put("assistantCode", assistantCode);
|
request.getMetadata().put("assistantCode", assistantCode);
|
||||||
@@ -1243,6 +1226,7 @@ public class AgentRunService {
|
|||||||
runRuntimeCallbackSafely(
|
runRuntimeCallbackSafely(
|
||||||
() -> handleRuntimeEvent(event, requestId, runOutput, answer,
|
() -> handleRuntimeEvent(event, requestId, runOutput, answer,
|
||||||
assistantAccumulator, legacyThinkingTagParser,
|
assistantAccumulator, legacyThinkingTagParser,
|
||||||
|
knowledgeRetrievalStatusTracker,
|
||||||
chatContext, finished, persistChatlog),
|
chatContext, finished, persistChatlog),
|
||||||
requestId, runOutput, chatContext, finished, persistChatlog);
|
requestId, runOutput, chatContext, finished, persistChatlog);
|
||||||
}
|
}
|
||||||
@@ -1532,7 +1516,8 @@ public class AgentRunService {
|
|||||||
AtomicBoolean finished,
|
AtomicBoolean finished,
|
||||||
boolean persistChatlog) {
|
boolean persistChatlog) {
|
||||||
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
||||||
new LegacyThinkingTagParser(), chatContext, finished, persistChatlog);
|
new LegacyThinkingTagParser(), new KnowledgeRetrievalStatusTracker(),
|
||||||
|
chatContext, finished, persistChatlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
||||||
@@ -1544,6 +1529,35 @@ public class AgentRunService {
|
|||||||
ChatRuntimeContext chatContext,
|
ChatRuntimeContext chatContext,
|
||||||
AtomicBoolean finished,
|
AtomicBoolean finished,
|
||||||
boolean persistChatlog) {
|
boolean persistChatlog) {
|
||||||
|
handleRuntimeEvent(event, requestId, runOutput, answer, assistantAccumulator,
|
||||||
|
legacyThinkingTagParser, new KnowledgeRetrievalStatusTracker(),
|
||||||
|
chatContext, finished, persistChatlog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单个 Runtime 事件投影到聊天协议,并复用本轮知识库工具状态追踪器。
|
||||||
|
*
|
||||||
|
* @param event Runtime 事件
|
||||||
|
* @param requestId 请求 ID
|
||||||
|
* @param runOutput 运行输出
|
||||||
|
* @param answer 回答累积器
|
||||||
|
* @param assistantAccumulator Assistant 结构化累积器
|
||||||
|
* @param legacyThinkingTagParser 旧思考标签解析器
|
||||||
|
* @param knowledgeRetrievalStatusTracker 知识库工具状态追踪器
|
||||||
|
* @param chatContext 聊天上下文
|
||||||
|
* @param finished 终态仲裁标记
|
||||||
|
* @param persistChatlog 是否持久化聊天日志
|
||||||
|
*/
|
||||||
|
private void handleRuntimeEvent(AgentRuntimeEvent event,
|
||||||
|
String requestId,
|
||||||
|
AgentRunOutput runOutput,
|
||||||
|
StringBuilder answer,
|
||||||
|
ChatAssistantAccumulator assistantAccumulator,
|
||||||
|
LegacyThinkingTagParser legacyThinkingTagParser,
|
||||||
|
KnowledgeRetrievalStatusTracker knowledgeRetrievalStatusTracker,
|
||||||
|
ChatRuntimeContext chatContext,
|
||||||
|
AtomicBoolean finished,
|
||||||
|
boolean persistChatlog) {
|
||||||
if (event == null || event.getEventType() == null) {
|
if (event == null || event.getEventType() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1661,6 +1675,17 @@ public class AgentRunService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Map<String, Object> toolPayload = toolStatus;
|
Map<String, Object> toolPayload = toolStatus;
|
||||||
|
if (isKnowledgeToolEvent(event)) {
|
||||||
|
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
|
||||||
|
knowledgeRetrievalStatusTracker.update(event));
|
||||||
|
LOG.info("Agent runtime knowledge tool call, requestId={}, toolCallId={}, toolName={}",
|
||||||
|
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"));
|
||||||
|
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
|
||||||
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
||||||
|
legacyThinkingTagParser, finished, persistChatlog);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
|
if (!runOutput.emitRuntimeEvent(publicRuntimeEvent(event, toolPayload))) {
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
||||||
legacyThinkingTagParser, finished, persistChatlog);
|
legacyThinkingTagParser, finished, persistChatlog);
|
||||||
@@ -1683,6 +1708,20 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
||||||
Map<String, Object> toolPayload = toolStatus;
|
Map<String, Object> toolPayload = toolStatus;
|
||||||
|
if (isKnowledgeToolEvent(event)) {
|
||||||
|
Map<String, Object> statusPayload = buildKnowledgeRetrievalStatusPayload(
|
||||||
|
knowledgeRetrievalStatusTracker.update(event));
|
||||||
|
LOG.info("Agent runtime knowledge tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
||||||
|
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
||||||
|
stringValue(statusPayload, "status"));
|
||||||
|
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, statusPayload)) {
|
||||||
|
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
||||||
|
legacyThinkingTagParser, finished, persistChatlog);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
legacyThinkingTagParser.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
LOG.info("Agent runtime tool result, requestId={}, toolCallId={}, toolName={}, status={}",
|
||||||
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
requestId, event.getToolCallId(), stringValue(toolPayload, "toolName"),
|
||||||
stringValue(toolPayload, "status"));
|
stringValue(toolPayload, "status"));
|
||||||
@@ -1708,10 +1747,7 @@ public class AgentRunService {
|
|||||||
if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
|
if (event.getEventType() == AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL) {
|
||||||
LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
|
LOG.info("Agent runtime knowledge retrieval, requestId={}, payload={}, metadata={}",
|
||||||
requestId, event.getPayload(), event.getMetadata());
|
requestId, event.getPayload(), event.getMetadata());
|
||||||
if (!sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS, buildKnowledgeRetrievalStatusPayload(event))) {
|
// 文档摘要事件用于引用与监察;UI 完成态统一以 TOOL_RESULT 为准。
|
||||||
cancelDisconnectedRun(requestId, chatContext, answer, assistantAccumulator,
|
|
||||||
legacyThinkingTagParser, finished, persistChatlog);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|
if (event.getEventType() == AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED
|
||||||
@@ -1769,6 +1805,10 @@ public class AgentRunService {
|
|||||||
if (event.getEventType() == AgentRuntimeEventType.FAILED) {
|
if (event.getEventType() == AgentRuntimeEventType.FAILED) {
|
||||||
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
|
emitAssistantSegments(legacyThinkingTagParser.finish(), requestId, runOutput, chatContext,
|
||||||
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
answer, assistantAccumulator, legacyThinkingTagParser, finished, persistChatlog);
|
||||||
|
if (knowledgeRetrievalStatusTracker.failActiveCalls()) {
|
||||||
|
sendEnvelope(runOutput, ChatDomain.BUSINESS, ChatType.STATUS,
|
||||||
|
buildKnowledgeRetrievalStatusPayload("error"));
|
||||||
|
}
|
||||||
runOutput.emitRuntimeEvent(event);
|
runOutput.emitRuntimeEvent(event);
|
||||||
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
|
assistantAccumulator.finalizePendingSkillInvocations("FAILED", "技能调用失败");
|
||||||
if (persistChatlog) {
|
if (persistChatlog) {
|
||||||
@@ -2340,13 +2380,30 @@ public class AgentRunService {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentMessage buildAgentMessage(String prompt, List<AgentBoundMedia> media) {
|
/**
|
||||||
|
* 构建发送给 AgentScope 的用户消息。
|
||||||
|
*
|
||||||
|
* <p>文档正文属于用户提供的不可信材料,作为用户内容块进入本轮模型调用和 AgentScope
|
||||||
|
* memory。聊天记录仍单独保存原始输入与附件引用,页面不会展示正文内容块。</p>
|
||||||
|
*
|
||||||
|
* @param prompt 用户输入
|
||||||
|
* @param media 图片附件
|
||||||
|
* @param documentContext 本轮选中的文档上下文
|
||||||
|
* @return 可持久化的运行时用户消息
|
||||||
|
*/
|
||||||
|
private AgentMessage buildAgentMessage(String prompt,
|
||||||
|
List<AgentBoundMedia> media,
|
||||||
|
AgentDocumentContext documentContext) {
|
||||||
AgentMessage message = new AgentMessage();
|
AgentMessage message = new AgentMessage();
|
||||||
message.setRole(AgentMessageRole.USER);
|
message.setRole(AgentMessageRole.USER);
|
||||||
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
|
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
|
||||||
if (prompt != null && !prompt.isBlank()) {
|
if (prompt != null && !prompt.isBlank()) {
|
||||||
blocks.add(new AgentTextBlock(prompt));
|
blocks.add(new AgentTextBlock(prompt));
|
||||||
}
|
}
|
||||||
|
if (documentContext != null && documentContext.text() != null
|
||||||
|
&& !documentContext.text().isBlank()) {
|
||||||
|
blocks.add(new AgentTextBlock(documentContext.text()));
|
||||||
|
}
|
||||||
if (media != null) {
|
if (media != null) {
|
||||||
for (AgentBoundMedia item : media) {
|
for (AgentBoundMedia item : media) {
|
||||||
AgentMediaBlock image = new AgentMediaBlock("image");
|
AgentMediaBlock image = new AgentMediaBlock("image");
|
||||||
@@ -2815,7 +2872,8 @@ public class AgentRunService {
|
|||||||
Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
|
Map<String, Object> rawPayload = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||||
Map<String, Object> payload = selectPayload(rawPayload,
|
Map<String, Object> payload = selectPayload(rawPayload,
|
||||||
"name", "status", "success", "toolDisplayName", "toolName",
|
"name", "status", "success", "toolDisplayName", "toolName",
|
||||||
"skillDisplayName", "skillId");
|
"skillDisplayName", "skillId", "toolCategory",
|
||||||
|
"knowledgeId", "knowledgeName", "knowledgeRuntimeName");
|
||||||
String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
|
String toolCallId = firstText(event.getToolCallId(), stringValue(rawPayload, "toolCallId"));
|
||||||
if (toolCallId != null && !toolCallId.isBlank()) {
|
if (toolCallId != null && !toolCallId.isBlank()) {
|
||||||
payload.put("toolCallId", toolCallId);
|
payload.put("toolCallId", toolCallId);
|
||||||
@@ -2951,17 +3009,110 @@ public class AgentRunService {
|
|||||||
/**
|
/**
|
||||||
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
|
* 构建知识库检索状态载荷,确保前端可按稳定 key 合并同一轮状态行。
|
||||||
*
|
*
|
||||||
* @param event 知识库检索运行时事件
|
* @param status running、done 或 error
|
||||||
* @return 知识库检索状态载荷
|
* @return 知识库检索状态载荷
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(AgentRuntimeEvent event) {
|
private Map<String, Object> buildKnowledgeRetrievalStatusPayload(String status) {
|
||||||
|
String normalizedStatus = "running".equals(status) || "error".equals(status)
|
||||||
|
? status : "done";
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("statusKey", "knowledge-retrieval");
|
payload.put("statusKey", "knowledge-retrieval");
|
||||||
payload.put("status", "done");
|
payload.put("status", normalizedStatus);
|
||||||
payload.put("label", "已检索知识库");
|
payload.put("label", switch (normalizedStatus) {
|
||||||
|
case "running" -> "正在检索知识库";
|
||||||
|
case "error" -> "知识库检索失败";
|
||||||
|
default -> "已检索知识库";
|
||||||
|
});
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断标准工具生命周期事件是否属于知识库工具。
|
||||||
|
*
|
||||||
|
* @param event 运行时工具事件
|
||||||
|
* @return 知识库工具事件时为 true
|
||||||
|
*/
|
||||||
|
private boolean isKnowledgeToolEvent(AgentRuntimeEvent event) {
|
||||||
|
String category = stringPayload(event, "toolCategory");
|
||||||
|
if ("KNOWLEDGE".equalsIgnoreCase(category)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String toolName = firstText(stringPayload(event, "toolName"), stringPayload(event, "name"));
|
||||||
|
if (toolName == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String normalizedName = toolName.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return "retrieve_knowledge".equals(normalizedName)
|
||||||
|
|| normalizedName.startsWith("retrieve_knowledge_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合同一批知识库工具调用,避免并行检索中首个结果提前结束 UI 状态。
|
||||||
|
*/
|
||||||
|
static final class KnowledgeRetrievalStatusTracker {
|
||||||
|
|
||||||
|
private final Set<String> activeToolCallIds = new LinkedHashSet<>();
|
||||||
|
private boolean failed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用一次知识库工具生命周期事件。
|
||||||
|
*
|
||||||
|
* @param event TOOL_CALL 或 TOOL_RESULT 事件
|
||||||
|
* @return 聚合后的 running、done 或 error 状态
|
||||||
|
*/
|
||||||
|
String update(AgentRuntimeEvent event) {
|
||||||
|
String toolCallId = toolCallIdentity(event);
|
||||||
|
if (event.getEventType() == AgentRuntimeEventType.TOOL_CALL) {
|
||||||
|
if (activeToolCallIds.isEmpty()) {
|
||||||
|
failed = false;
|
||||||
|
}
|
||||||
|
activeToolCallIds.add(toolCallId);
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
if (event.getEventType() == AgentRuntimeEventType.TOOL_RESULT) {
|
||||||
|
activeToolCallIds.remove(toolCallId);
|
||||||
|
failed = failed || !toolSucceeded(event);
|
||||||
|
if (!activeToolCallIds.isEmpty()) {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
return failed ? "error" : "done";
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Knowledge status only accepts TOOL_CALL or TOOL_RESULT events.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将运行失败时仍未结束的知识库调用收口为失败。
|
||||||
|
*
|
||||||
|
* @return 存在未结束调用时为 true
|
||||||
|
*/
|
||||||
|
boolean failActiveCalls() {
|
||||||
|
if (activeToolCallIds.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
activeToolCallIds.clear();
|
||||||
|
failed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toolCallIdentity(AgentRuntimeEvent event) {
|
||||||
|
String toolCallId = event.getToolCallId();
|
||||||
|
if (toolCallId == null || toolCallId.isBlank()) {
|
||||||
|
Object payloadId = event.getPayload() == null ? null : event.getPayload().get("toolCallId");
|
||||||
|
toolCallId = payloadId == null ? event.getEventId() : String.valueOf(payloadId);
|
||||||
|
}
|
||||||
|
return toolCallId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean toolSucceeded(AgentRuntimeEvent event) {
|
||||||
|
Map<String, Object> payload = event.getPayload() == null ? Map.of() : event.getPayload();
|
||||||
|
if (Boolean.FALSE.equals(payload.get("success"))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Object status = payload.get("status");
|
||||||
|
return status == null || !"FAILED".equalsIgnoreCase(String.valueOf(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
|
* 构建不含 Runtime 原始上下文的内存压缩公开状态载荷。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.AgentDefinition;
|
import com.easyagents.agent.runtime.AgentDefinition;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
import com.easyagents.agent.runtime.tool.AgentToolInvoker;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,7 @@ public class AgentRuntimeBundle {
|
|||||||
|
|
||||||
private AgentDefinition definition;
|
private AgentDefinition definition;
|
||||||
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
private Map<String, AgentToolInvoker> toolInvokers = new LinkedHashMap<>();
|
||||||
private Map<String, AgentKnowledgeRetriever> knowledgeRetrievers = new LinkedHashMap<>();
|
private List<AgentKnowledgeRegistration> knowledgeRegistrations = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 定义。
|
* 获取 Agent 定义。
|
||||||
@@ -57,16 +59,18 @@ public class AgentRuntimeBundle {
|
|||||||
*
|
*
|
||||||
* @return 知识库检索器
|
* @return 知识库检索器
|
||||||
*/
|
*/
|
||||||
public Map<String, AgentKnowledgeRetriever> getKnowledgeRetrievers() {
|
public List<AgentKnowledgeRegistration> getKnowledgeRegistrations() {
|
||||||
return knowledgeRetrievers;
|
return knowledgeRegistrations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置知识库检索器。
|
* 设置知识库检索器。
|
||||||
*
|
*
|
||||||
* @param knowledgeRetrievers 知识库检索器
|
* @param knowledgeRegistrations 知识库运行时绑定
|
||||||
*/
|
*/
|
||||||
public void setKnowledgeRetrievers(Map<String, AgentKnowledgeRetriever> knowledgeRetrievers) {
|
public void setKnowledgeRegistrations(List<AgentKnowledgeRegistration> knowledgeRegistrations) {
|
||||||
this.knowledgeRetrievers = knowledgeRetrievers == null ? new LinkedHashMap<>() : knowledgeRetrievers;
|
this.knowledgeRegistrations = knowledgeRegistrations == null
|
||||||
|
? new ArrayList<>()
|
||||||
|
: new ArrayList<>(knowledgeRegistrations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import com.easyagents.agent.runtime.AgentRuntimeContext;
|
|||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
|
||||||
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgePolicy;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeToolNames;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
import com.easyagents.agent.runtime.memory.AgentMemoryCompressionParameter;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
||||||
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
import com.easyagents.agent.runtime.memory.AgentMemoryType;
|
||||||
@@ -118,11 +119,11 @@ public class AgentRuntimeCompiler {
|
|||||||
bundle.setDefinition(definition);
|
bundle.setDefinition(definition);
|
||||||
|
|
||||||
compileTools(agent, definition, bundle);
|
compileTools(agent, definition, bundle);
|
||||||
|
compileKnowledge(agent, definition, bundle);
|
||||||
if (agentBuiltinToolsConfigResolver != null) {
|
if (agentBuiltinToolsConfigResolver != null) {
|
||||||
validateBuiltinTools(definition,
|
validateBuiltinTools(definition,
|
||||||
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
|
agentBuiltinToolsConfigResolver.resolvePublishedRuntime(agent.getExecutionConfigJson()));
|
||||||
}
|
}
|
||||||
compileKnowledge(agent, definition, bundle);
|
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +295,7 @@ public class AgentRuntimeCompiler {
|
|||||||
if (config.artifactPublish().enabled()) {
|
if (config.artifactPublish().enabled()) {
|
||||||
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
|
specs.add(buildArtifactPublishSpec(config.artifactPublish()));
|
||||||
}
|
}
|
||||||
assertToolBudget(specs, definition.getMcpSpecs());
|
assertToolBudget(specs, definition.getMcpSpecs(), definition.getKnowledgeSpecs().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void attachBuiltinTools(Agent agent,
|
private void attachBuiltinTools(Agent agent,
|
||||||
@@ -510,11 +511,21 @@ public class AgentRuntimeCompiler {
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验内置工具与普通工具、知识库工具及 MCP 工具不存在运行名冲突。
|
||||||
|
*
|
||||||
|
* @param definition 已编译 Agent 定义
|
||||||
|
* @param builtinNames 待启用内置工具名称
|
||||||
|
* @throws BusinessException 工具名称冲突时抛出
|
||||||
|
*/
|
||||||
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
|
private void assertNoBuiltinNameConflict(AgentDefinition definition, Set<String> builtinNames) {
|
||||||
Set<String> existing = new LinkedHashSet<>();
|
Set<String> existing = new LinkedHashSet<>();
|
||||||
for (AgentToolSpec spec : definition.getToolSpecs()) {
|
for (AgentToolSpec spec : definition.getToolSpecs()) {
|
||||||
existing.add(spec.getName());
|
existing.add(spec.getName());
|
||||||
}
|
}
|
||||||
|
for (AgentKnowledgeSpec spec : definition.getKnowledgeSpecs()) {
|
||||||
|
existing.add(AgentKnowledgeToolNames.build(spec.getRuntimeName()));
|
||||||
|
}
|
||||||
for (McpSpec mcp : definition.getMcpSpecs()) {
|
for (McpSpec mcp : definition.getMcpSpecs()) {
|
||||||
if (mcp.getFrozenToolManifest() != null) {
|
if (mcp.getFrozenToolManifest() != null) {
|
||||||
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
|
mcp.getFrozenToolManifest().forEach(entry -> existing.add(entry.getName()));
|
||||||
@@ -540,6 +551,14 @@ public class AgentRuntimeCompiler {
|
|||||||
assertToolBudget(toolSpecs, mcpSpecs, 0);
|
assertToolBudget(toolSpecs, mcpSpecs, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验最终工具数量和 Schema 大小预算。
|
||||||
|
*
|
||||||
|
* @param toolSpecs 静态 Tool 声明
|
||||||
|
* @param mcpSpecs MCP 声明
|
||||||
|
* @param additionalToolCount 知识库等额外工具数量
|
||||||
|
* @throws BusinessException 超出预算时抛出
|
||||||
|
*/
|
||||||
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
|
private void assertToolBudget(List<AgentToolSpec> toolSpecs,
|
||||||
List<McpSpec> mcpSpecs,
|
List<McpSpec> mcpSpecs,
|
||||||
int additionalToolCount) {
|
int additionalToolCount) {
|
||||||
@@ -570,7 +589,7 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
|
if (toolCount > MAX_RUNTIME_TOOL_COUNT) {
|
||||||
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少直接工具或 Skill 绑定");
|
throw new BusinessException("Agent Runtime Tool 数量超过 128 个,请减少工具、知识库或 Skill 绑定");
|
||||||
}
|
}
|
||||||
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
|
if (schemaBytes > MAX_RUNTIME_SCHEMA_BYTES) {
|
||||||
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
throw new BusinessException("Agent Runtime Tool Schema 超过 2 MiB,请减少工具或精简 Schema");
|
||||||
@@ -591,12 +610,27 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 EasyFlow 知识库绑定编译为一库一工具所需的声明和 Retriever 绑定。
|
||||||
|
*
|
||||||
|
* @param agent Agent 发布视图
|
||||||
|
* @param definition 中立 Agent 定义
|
||||||
|
* @param bundle 运行时编译结果
|
||||||
|
* @throws BusinessException 知识库不存在、英文运行名非法或工具名冲突时抛出
|
||||||
|
*/
|
||||||
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
private void compileKnowledge(Agent agent, AgentDefinition definition, AgentRuntimeBundle bundle) {
|
||||||
if (agent.getKnowledgeBindings() == null) {
|
if (agent.getKnowledgeBindings() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
List<AgentKnowledgeSpec> specs = new ArrayList<>();
|
List<AgentKnowledgeSpec> specs = new ArrayList<>();
|
||||||
Map<String, com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever> retrievers = new LinkedHashMap<>();
|
List<AgentKnowledgeRegistration> registrations = new ArrayList<>();
|
||||||
|
Set<String> knowledgeToolNames = new LinkedHashSet<>();
|
||||||
|
Set<String> existingToolNames = new LinkedHashSet<>();
|
||||||
|
definition.getToolSpecs().stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(AgentToolSpec::getName)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.forEach(existingToolNames::add);
|
||||||
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
for (AgentKnowledgeBinding binding : agent.getKnowledgeBindings()) {
|
||||||
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
if (!Boolean.TRUE.equals(binding.getEnabled())) {
|
||||||
continue;
|
continue;
|
||||||
@@ -607,9 +641,9 @@ public class AgentRuntimeCompiler {
|
|||||||
}
|
}
|
||||||
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
AgentKnowledgeSpec spec = new AgentKnowledgeSpec();
|
||||||
spec.setKnowledgeId(binding.getKnowledgeId().toString());
|
spec.setKnowledgeId(binding.getKnowledgeId().toString());
|
||||||
|
spec.setRuntimeName(requireKnowledgeRuntimeName(knowledge));
|
||||||
spec.setName(knowledge.getTitle());
|
spec.setName(knowledge.getTitle());
|
||||||
spec.setDescription(knowledge.getDescription());
|
spec.setDescription(knowledge.getDescription());
|
||||||
spec.setRetrievalMode(AgentKnowledgePolicy.AGENTIC);
|
|
||||||
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
|
spec.getMetadata().put("knowledgeType", knowledge.getCollectionType());
|
||||||
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
|
spec.getMetadata().put("faqCollection", knowledge.isFaqCollection());
|
||||||
Integer limit = intValue(binding.getOptionsJson(), "limit");
|
Integer limit = intValue(binding.getOptionsJson(), "limit");
|
||||||
@@ -618,11 +652,37 @@ public class AgentRuntimeCompiler {
|
|||||||
if (threshold != null) {
|
if (threshold != null) {
|
||||||
spec.setScoreThreshold(threshold);
|
spec.setScoreThreshold(threshold);
|
||||||
}
|
}
|
||||||
|
String toolName = AgentKnowledgeToolNames.build(spec.getRuntimeName());
|
||||||
|
if (!knowledgeToolNames.add(toolName) || existingToolNames.contains(toolName)) {
|
||||||
|
throw new BusinessException("Agent 知识库工具运行名冲突:" + toolName);
|
||||||
|
}
|
||||||
specs.add(spec);
|
specs.add(spec);
|
||||||
retrievers.put(spec.getKnowledgeId(), request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold()));
|
registrations.add(new AgentKnowledgeRegistration(spec,
|
||||||
|
request -> retrieveKnowledge(binding, request.getQuery(), request.getLimit(), request.getScoreThreshold())));
|
||||||
}
|
}
|
||||||
definition.setKnowledgeSpecs(specs);
|
definition.setKnowledgeSpecs(specs);
|
||||||
bundle.setKnowledgeRetrievers(retrievers);
|
bundle.setKnowledgeRegistrations(registrations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取并校验知识库英文运行名。
|
||||||
|
*
|
||||||
|
* @param knowledge 知识库发布视图
|
||||||
|
* @return 合法英文运行名
|
||||||
|
* @throws BusinessException 英文运行名缺失或非法时抛出
|
||||||
|
*/
|
||||||
|
private String requireKnowledgeRuntimeName(DocumentCollection knowledge) {
|
||||||
|
String runtimeName = knowledge == null ? null : knowledge.getEnglishName();
|
||||||
|
try {
|
||||||
|
AgentKnowledgeToolNames.build(runtimeName);
|
||||||
|
return runtimeName.trim();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
String knowledgeName = knowledge == null || knowledge.getTitle() == null
|
||||||
|
? "未知知识库"
|
||||||
|
: knowledge.getTitle();
|
||||||
|
throw new BusinessException(400, 400, "知识库“" + knowledgeName
|
||||||
|
+ "”的英文名称不能为空,且只能包含字母、数字、下划线和连字符", exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
private AgentKnowledgeRetrievalResult retrieveKnowledge(AgentKnowledgeBinding binding, String query, int limit, double scoreThreshold) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.time.Instant;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -563,7 +564,9 @@ public final class AguiAgentRunOutput implements AgentRunOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isHiddenToolName(String toolName) {
|
private static boolean isHiddenToolName(String toolName) {
|
||||||
return "retrieve_knowledge".equalsIgnoreCase(toolName)
|
String normalizedName = toolName == null ? "" : toolName.trim().toLowerCase(Locale.ROOT);
|
||||||
|
return "retrieve_knowledge".equals(normalizedName)
|
||||||
|
|| normalizedName.startsWith("retrieve_knowledge_")
|
||||||
|| "context_reload".equalsIgnoreCase(toolName)
|
|| "context_reload".equalsIgnoreCase(toolName)
|
||||||
|| "__fragment__".equalsIgnoreCase(toolName);
|
|| "__fragment__".equalsIgnoreCase(toolName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class AgentSkillReferenceProvider implements SkillReferenceProvider {
|
|||||||
ids.add(agent.getId());
|
ids.add(agent.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
List<String> result = new ArrayList<>();
|
List<String> result = new ArrayList<>();
|
||||||
for (Agent agent : agentService.listByIds(ids)) {
|
for (Agent agent : agentService.listByIds(ids)) {
|
||||||
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”");
|
result.add("智能体“" + (agent.getName() == null ? "未命名智能体" : agent.getName()) + "”");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.junit.Test;
|
|||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
import tech.easyflow.agent.enums.AgentToolType;
|
import tech.easyflow.agent.enums.AgentToolType;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
||||||
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
import tech.easyflow.ai.entity.Mcp;
|
import tech.easyflow.ai.entity.Mcp;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
@@ -44,6 +45,8 @@ public class AgentDefinitionCompilerMcpTest {
|
|||||||
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
setField(toolCompiler, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
||||||
setField(toolCompiler, "mcpService", mcpService(mcp));
|
setField(toolCompiler, "mcpService", mcpService(mcp));
|
||||||
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
||||||
|
setField(compiler, "agentSkillRuntimeCompiler", new AgentSkillRuntimeCompiler(
|
||||||
|
null, toolCompiler, new com.fasterxml.jackson.databind.ObjectMapper()));
|
||||||
|
|
||||||
Agent agent = agent(modelId, mcpId);
|
Agent agent = agent(modelId, mcpId);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
|||||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||||
|
import com.easyagents.agent.runtime.message.AgentTextBlock;
|
||||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
@@ -69,6 +70,28 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
*/
|
*/
|
||||||
public class AgentRunServiceDraftAndHitlTest {
|
public class AgentRunServiceDraftAndHitlTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文档上下文随用户消息进入可持久化 memory,同时保持独立内容块边界。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void buildAgentMessageShouldIncludeDocumentContext() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
AgentDocumentContext documentContext = new AgentDocumentContext(
|
||||||
|
"\n<<<DOCUMENT name=\"demo.docx\">>>\n正文\n<<<END_DOCUMENT>>>", 8, List.of());
|
||||||
|
|
||||||
|
AgentMessage message = invoke(service, "buildAgentMessage",
|
||||||
|
new Class<?>[]{String.class, List.class, AgentDocumentContext.class},
|
||||||
|
"请介绍文档", List.of(), documentContext);
|
||||||
|
|
||||||
|
Assert.assertEquals(2, message.getContentBlocks().size());
|
||||||
|
Assert.assertEquals("请介绍文档",
|
||||||
|
((AgentTextBlock) message.getContentBlocks().get(0)).getText());
|
||||||
|
Assert.assertEquals(documentContext.text(),
|
||||||
|
((AgentTextBlock) message.getContentBlocks().get(1)).getText());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用于 owner 恢复测试的运行描述。
|
* 创建用于 owner 恢复测试的运行描述。
|
||||||
*
|
*
|
||||||
@@ -453,18 +476,48 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证知识检索状态不会携带命中文档和内部 metadata。
|
* 验证知识库工具开始事件会投影为脱敏的检索中状态。
|
||||||
*
|
*
|
||||||
* @throws Exception 反射调用失败时抛出
|
* @throws Exception 反射调用失败时抛出
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
public void handleRuntimeEventShouldWhitelistKnowledgeStatusPayload() throws Exception {
|
public void handleRuntimeEventShouldProjectKnowledgeToolCallAsRunningStatus() throws Exception {
|
||||||
AgentRunService service = new AgentRunService();
|
AgentRunService service = new AgentRunService();
|
||||||
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
|
||||||
|
event.setToolCallId("knowledge-call-1");
|
||||||
|
event.getPayload().put("toolCallId", "knowledge-call-1");
|
||||||
|
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
||||||
|
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
||||||
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
||||||
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
|
event.getPayload().put("metadata", Map.of("sourceUri", "private://document"));
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, emitter.envelopes.size());
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
|
||||||
|
Assert.assertEquals(Map.of(
|
||||||
|
"label", "正在检索知识库",
|
||||||
|
"status", "running",
|
||||||
|
"statusKey", "knowledge-retrieval"), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证知识库工具结果事件会投影为完成状态。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void handleRuntimeEventShouldProjectKnowledgeToolResultAsDoneStatus() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
AgentRuntimeEvent event = knowledgeToolEvent(
|
||||||
|
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", true);
|
||||||
|
|
||||||
invoke(service, "handleRuntimeEvent",
|
invoke(service, "handleRuntimeEvent",
|
||||||
runtimeEventParameterTypes(),
|
runtimeEventParameterTypes(),
|
||||||
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
||||||
@@ -479,6 +532,48 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
"statusKey", "knowledge-retrieval"), payload);
|
"statusKey", "knowledge-retrieval"), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文档摘要事件不会抢先把知识库工具状态标记为完成。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void handleRuntimeEventShouldNotCompleteKnowledgeStatusFromDocumentEvent() throws Exception {
|
||||||
|
AgentRunService service = new AgentRunService();
|
||||||
|
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.KNOWLEDGE_RETRIEVAL);
|
||||||
|
event.getPayload().put("documents", List.of(Map.of("chunkContent", "private chunk")));
|
||||||
|
|
||||||
|
invoke(service, "handleRuntimeEvent",
|
||||||
|
runtimeEventParameterTypes(),
|
||||||
|
event, "request-knowledge", legacyOutput(emitter), new StringBuilder(),
|
||||||
|
new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);
|
||||||
|
|
||||||
|
Assert.assertTrue(emitter.envelopes.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并行知识库调用全部结束后才进入终态,并保留任一调用失败结果。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void knowledgeStatusTrackerShouldAggregateParallelToolCalls() {
|
||||||
|
AgentRunService.KnowledgeRetrievalStatusTracker tracker =
|
||||||
|
new AgentRunService.KnowledgeRetrievalStatusTracker();
|
||||||
|
AgentRuntimeEvent firstCall = knowledgeToolEvent(
|
||||||
|
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-1", true);
|
||||||
|
AgentRuntimeEvent secondCall = knowledgeToolEvent(
|
||||||
|
AgentRuntimeEventType.TOOL_CALL, "knowledge-call-2", true);
|
||||||
|
AgentRuntimeEvent firstResult = knowledgeToolEvent(
|
||||||
|
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-1", false);
|
||||||
|
AgentRuntimeEvent secondResult = knowledgeToolEvent(
|
||||||
|
AgentRuntimeEventType.TOOL_RESULT, "knowledge-call-2", true);
|
||||||
|
|
||||||
|
Assert.assertEquals("running", tracker.update(firstCall));
|
||||||
|
Assert.assertEquals("running", tracker.update(secondCall));
|
||||||
|
Assert.assertEquals("running", tracker.update(firstResult));
|
||||||
|
Assert.assertEquals("error", tracker.update(secondResult));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
* 验证完成事件不会再次发送正文消息,只用于最终收口。
|
||||||
*
|
*
|
||||||
@@ -1556,6 +1651,29 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库工具生命周期测试事件。
|
||||||
|
*
|
||||||
|
* @param eventType 工具开始或结果事件类型
|
||||||
|
* @param toolCallId 工具调用 ID
|
||||||
|
* @param success 工具结果是否成功
|
||||||
|
* @return 知识库工具事件
|
||||||
|
*/
|
||||||
|
private AgentRuntimeEvent knowledgeToolEvent(AgentRuntimeEventType eventType,
|
||||||
|
String toolCallId,
|
||||||
|
boolean success) {
|
||||||
|
AgentRuntimeEvent event = AgentRuntimeEvent.of(eventType);
|
||||||
|
event.setToolCallId(toolCallId);
|
||||||
|
event.getPayload().put("toolCallId", toolCallId);
|
||||||
|
event.getPayload().put("toolName", "retrieve_knowledge_homeinn_faq");
|
||||||
|
event.getPayload().put("toolCategory", "KNOWLEDGE");
|
||||||
|
if (eventType == AgentRuntimeEventType.TOOL_RESULT) {
|
||||||
|
event.getPayload().put("success", success);
|
||||||
|
event.getPayload().put("status", success ? "SUCCESS" : "FAILED");
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
private Class<?>[] runtimeEventParameterTypes() {
|
private Class<?>[] runtimeEventParameterTypes() {
|
||||||
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
return new Class<?>[]{AgentRuntimeEvent.class, String.class, AgentRunOutput.class, StringBuilder.class,
|
||||||
ChatAssistantAccumulator.class,
|
ChatAssistantAccumulator.class,
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeDocument;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRegistration;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalRequest;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetrievalResult;
|
||||||
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeSpec;
|
||||||
|
import com.easyagents.core.document.Document;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.agent.runtime.skill.AgentSkillRuntimeCompiler;
|
||||||
|
import tech.easyflow.agent.runtime.tool.AgentToolRuntimeCompiler;
|
||||||
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.entity.ModelProvider;
|
||||||
|
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 知识库一库一工具运行时编译测试。
|
||||||
|
*/
|
||||||
|
public class AgentRuntimeCompilerKnowledgeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证知识库英文名称、描述和检索配置会编译到中立声明及独立 Retriever。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldBuildOneKnowledgeRegistrationWithEnglishRuntimeName() throws Exception {
|
||||||
|
AtomicReference<KnowledgeRetrievalRequest> capturedRequest = new AtomicReference<>();
|
||||||
|
Document document = new Document("如家酒店通常在入住日 14:00 后办理入住。");
|
||||||
|
document.setId("chunk-1");
|
||||||
|
document.setTitle("如家 FAQ");
|
||||||
|
document.setScore(0.92D);
|
||||||
|
document.addMetadata("documentId", "faq-document-1");
|
||||||
|
document.addMetadata("chunkId", "faq-chunk-1");
|
||||||
|
AgentRuntimeCompiler compiler = compiler(capturedRequest, List.of(document));
|
||||||
|
Agent agent = agent(knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L)));
|
||||||
|
|
||||||
|
AgentRuntimeBundle bundle = compiler.compile(agent);
|
||||||
|
|
||||||
|
Assert.assertEquals(1, bundle.getDefinition().getKnowledgeSpecs().size());
|
||||||
|
AgentKnowledgeSpec spec = bundle.getDefinition().getKnowledgeSpecs().get(0);
|
||||||
|
Assert.assertEquals("homeinn_faq", spec.getRuntimeName());
|
||||||
|
Assert.assertEquals("如家 FAQ", spec.getName());
|
||||||
|
Assert.assertTrue(spec.getDescription().contains("入住"));
|
||||||
|
Assert.assertEquals(7, spec.getLimit());
|
||||||
|
Assert.assertEquals(0.55D, spec.getScoreThreshold(), 0.0001D);
|
||||||
|
Assert.assertEquals(1, bundle.getKnowledgeRegistrations().size());
|
||||||
|
|
||||||
|
AgentKnowledgeRegistration registration = bundle.getKnowledgeRegistrations().get(0);
|
||||||
|
AgentKnowledgeRetrievalRequest retrievalRequest = new AgentKnowledgeRetrievalRequest();
|
||||||
|
retrievalRequest.setQuery("如家几点入住");
|
||||||
|
retrievalRequest.setLimit(spec.getLimit());
|
||||||
|
retrievalRequest.setScoreThreshold(spec.getScoreThreshold());
|
||||||
|
AgentKnowledgeRetrievalResult result = registration.getRetriever().retrieve(retrievalRequest);
|
||||||
|
|
||||||
|
Assert.assertEquals("如家几点入住", capturedRequest.get().getQuery());
|
||||||
|
Assert.assertEquals(Integer.valueOf(7), capturedRequest.get().getLimit());
|
||||||
|
Assert.assertEquals(Double.valueOf(0.55D), capturedRequest.get().getMinSimilarity());
|
||||||
|
Assert.assertEquals("AGENT_KNOWLEDGE", capturedRequest.get().getCallerType());
|
||||||
|
Assert.assertEquals(1, result.getDocuments().size());
|
||||||
|
AgentKnowledgeDocument mapped = result.getDocuments().get(0);
|
||||||
|
Assert.assertEquals("faq-document-1", mapped.getDocumentId());
|
||||||
|
Assert.assertEquals("faq-chunk-1", mapped.getChunkId());
|
||||||
|
Assert.assertEquals(0.92D, mapped.getScore(), 0.0001D);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证缺失知识库英文名称时在发布编译阶段明确失败。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldRejectMissingKnowledgeEnglishName() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
||||||
|
Agent agent = agent(knowledgeBinding(null, BigInteger.valueOf(20L)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
compiler.compile(agent);
|
||||||
|
Assert.fail("缺失英文名称时应拒绝编译");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("英文名称不能为空"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证多个知识库生成相同工具名时在编译阶段拒绝发布。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入依赖失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void compileShouldRejectDuplicateKnowledgeToolNames() throws Exception {
|
||||||
|
AgentRuntimeCompiler compiler = compiler(new AtomicReference<>(), List.of());
|
||||||
|
AgentKnowledgeBinding first = knowledgeBinding("homeinn_faq", BigInteger.valueOf(20L));
|
||||||
|
AgentKnowledgeBinding second = knowledgeBinding("homeinn_faq", BigInteger.valueOf(21L));
|
||||||
|
Agent agent = agent(first, second);
|
||||||
|
|
||||||
|
try {
|
||||||
|
compiler.compile(agent);
|
||||||
|
Assert.fail("重复知识库工具名时应拒绝编译");
|
||||||
|
} catch (BusinessException expected) {
|
||||||
|
Assert.assertTrue(expected.getMessage().contains("retrieve_knowledge_homeinn_faq"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅含测试模型与知识库服务的运行时编译器。
|
||||||
|
*
|
||||||
|
* @param capturedRequest 检索请求捕获器
|
||||||
|
* @param documents 检索服务返回文档
|
||||||
|
* @return 已注入依赖的编译器
|
||||||
|
* @throws Exception 反射注入失败时抛出
|
||||||
|
*/
|
||||||
|
private AgentRuntimeCompiler compiler(AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
||||||
|
List<Document> documents) throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AgentToolRuntimeCompiler toolCompiler = new AgentToolRuntimeCompiler();
|
||||||
|
AgentRuntimeCompiler compiler = new AgentRuntimeCompiler();
|
||||||
|
setField(compiler, "objectMapper", objectMapper);
|
||||||
|
setField(compiler, "modelService", modelService(model()));
|
||||||
|
setField(compiler, "documentCollectionService", documentCollectionService(capturedRequest, documents));
|
||||||
|
setField(compiler, "agentToolRuntimeCompiler", toolCompiler);
|
||||||
|
setField(compiler, "agentSkillRuntimeCompiler",
|
||||||
|
new AgentSkillRuntimeCompiler(null, toolCompiler, objectMapper));
|
||||||
|
return compiler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带知识库绑定的 Agent。
|
||||||
|
*
|
||||||
|
* @param bindings 知识库绑定
|
||||||
|
* @return Agent 测试对象
|
||||||
|
*/
|
||||||
|
private Agent agent(AgentKnowledgeBinding... bindings) {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.ONE);
|
||||||
|
agent.setName("如家助手");
|
||||||
|
agent.setModelId(BigInteger.TEN);
|
||||||
|
agent.setKnowledgeBindings(List.of(bindings));
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建冻结知识库绑定。
|
||||||
|
*
|
||||||
|
* @param englishName 知识库英文名称
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @return 知识库绑定
|
||||||
|
*/
|
||||||
|
private AgentKnowledgeBinding knowledgeBinding(String englishName, BigInteger knowledgeId) {
|
||||||
|
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
||||||
|
binding.setAgentId(BigInteger.ONE);
|
||||||
|
binding.setKnowledgeId(knowledgeId);
|
||||||
|
binding.setRetrievalMode("HYBRID");
|
||||||
|
binding.setEnabled(true);
|
||||||
|
binding.setOptionsJson(Map.of("limit", 7, "scoreThreshold", 0.55D));
|
||||||
|
binding.setResourceSnapshot(Map.of(
|
||||||
|
"id", knowledgeId,
|
||||||
|
"title", "如家 FAQ",
|
||||||
|
"description", "如家酒店入住、退房和会员服务常见问题",
|
||||||
|
"collectionType", "FAQ",
|
||||||
|
"englishName", englishName == null ? "" : englishName));
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建模型服务代理。
|
||||||
|
*
|
||||||
|
* @param model 测试模型
|
||||||
|
* @return 模型服务代理
|
||||||
|
*/
|
||||||
|
private ModelService modelService(Model model) {
|
||||||
|
return (ModelService) Proxy.newProxyInstance(
|
||||||
|
ModelService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{ModelService.class},
|
||||||
|
(proxy, method, args) -> "getModelInstance".equals(method.getName())
|
||||||
|
? model
|
||||||
|
: defaultValue(method.getReturnType()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库服务代理。
|
||||||
|
*
|
||||||
|
* @param capturedRequest 检索请求捕获器
|
||||||
|
* @param documents 返回文档
|
||||||
|
* @return 知识库服务代理
|
||||||
|
*/
|
||||||
|
private DocumentCollectionService documentCollectionService(
|
||||||
|
AtomicReference<KnowledgeRetrievalRequest> capturedRequest,
|
||||||
|
List<Document> documents) {
|
||||||
|
return (DocumentCollectionService) Proxy.newProxyInstance(
|
||||||
|
DocumentCollectionService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentCollectionService.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("search".equals(method.getName()) && args != null && args.length == 1
|
||||||
|
&& args[0] instanceof KnowledgeRetrievalRequest request) {
|
||||||
|
capturedRequest.set(request);
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建可映射为 AgentScope 模型配置的测试模型。
|
||||||
|
*
|
||||||
|
* @return 测试模型
|
||||||
|
*/
|
||||||
|
private Model model() {
|
||||||
|
ModelProvider provider = new ModelProvider();
|
||||||
|
provider.setProviderType("openai");
|
||||||
|
provider.setProviderName("OpenAI");
|
||||||
|
Model model = new Model();
|
||||||
|
model.setId(BigInteger.TEN);
|
||||||
|
model.setModelProvider(provider);
|
||||||
|
model.setModelName("gpt-test");
|
||||||
|
model.setEndpoint("https://example.com");
|
||||||
|
model.setRequestPath("/v1/chat/completions");
|
||||||
|
model.setApiKey("test-key");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回代理方法所需的默认值。
|
||||||
|
*
|
||||||
|
* @param type 返回类型
|
||||||
|
* @return 对应默认值
|
||||||
|
*/
|
||||||
|
private Object defaultValue(Class<?> type) {
|
||||||
|
if (type == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (type == int.class || type == long.class || type == short.class || type == byte.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (type == double.class || type == float.class) {
|
||||||
|
return 0D;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反射注入测试依赖。
|
||||||
|
*
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param fieldName 字段名称
|
||||||
|
* @param value 字段值
|
||||||
|
* @throws Exception 字段不存在或不可写时抛出
|
||||||
|
*/
|
||||||
|
private void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = target.getClass().getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,22 @@ public class AgentSkillReferenceProviderTest {
|
|||||||
"智能体“线上引用智能体”"), references);
|
"智能体“线上引用智能体”"), references);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 没有 Agent 引用 Skill 时不应执行空主键集合查询。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldSkipEntityQueryWhenSkillHasNoReferences() {
|
||||||
|
AgentService agentService = Mockito.mock(AgentService.class);
|
||||||
|
AgentSkillBindingService bindingService = Mockito.mock(AgentSkillBindingService.class);
|
||||||
|
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
Mockito.when(agentService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
|
||||||
|
AgentSkillReferenceProvider provider = new AgentSkillReferenceProvider(
|
||||||
|
agentService, bindingService);
|
||||||
|
|
||||||
|
Assert.assertTrue(provider.listReferences(BigInteger.TEN).isEmpty());
|
||||||
|
Mockito.verify(agentService, Mockito.never()).listByIds(Mockito.anyCollection());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 摘要。
|
* 创建 Agent 摘要。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
|||||||
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -164,7 +165,7 @@ public class DocumentSourceLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 优先打开经过上传记录验证的受管 URL,再执行普通公网 URL 校验与下载。
|
* 优先打开经过上传记录验证的受管 URL 和服务端已登记附件,再执行普通公网 URL 校验与下载。
|
||||||
*
|
*
|
||||||
* @param remoteUrl 远端 URL
|
* @param remoteUrl 远端 URL
|
||||||
* @param maxBytes 最大允许读取字节数
|
* @param maxBytes 最大允许读取字节数
|
||||||
@@ -176,6 +177,13 @@ public class DocumentSourceLoader {
|
|||||||
if (managed.isPresent()) {
|
if (managed.isPresent()) {
|
||||||
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
|
return DocumentInputStreamSupport.limit(managed.get(), maxBytes);
|
||||||
}
|
}
|
||||||
|
Optional<FileStorageWriteHandle> trusted =
|
||||||
|
fileStorageService.resolveTrustedFile(remoteUrl);
|
||||||
|
if (trusted.isPresent()) {
|
||||||
|
return DocumentInputStreamSupport.limit(
|
||||||
|
fileStorageService.readRecoverable(trusted.get()),
|
||||||
|
maxBytes);
|
||||||
|
}
|
||||||
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
|
return DocumentInputStreamSupport.openRemote(remoteUrl, maxBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ public final class DocumentImportBatchDtos {
|
|||||||
private BigInteger batchId;
|
private BigInteger batchId;
|
||||||
private String importMode;
|
private String importMode;
|
||||||
private String status;
|
private String status;
|
||||||
|
private Boolean actualCompleted;
|
||||||
private Integer totalCount;
|
private Integer totalCount;
|
||||||
private Long totalBytes;
|
private Long totalBytes;
|
||||||
private Integer completedCount;
|
private Integer completedCount;
|
||||||
@@ -292,6 +293,24 @@ public final class DocumentImportBatchDtos {
|
|||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取批次关联任务是否已经按真实文档状态全部完成。
|
||||||
|
*
|
||||||
|
* @return 全部完成时返回 {@code true}
|
||||||
|
*/
|
||||||
|
public Boolean getActualCompleted() {
|
||||||
|
return actualCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置批次关联任务是否已经按真实文档状态全部完成。
|
||||||
|
*
|
||||||
|
* @param actualCompleted 是否全部完成
|
||||||
|
*/
|
||||||
|
public void setActualCompleted(Boolean actualCompleted) {
|
||||||
|
this.actualCompleted = actualCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getTotalCount() {
|
public Integer getTotalCount() {
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
|
|||||||
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportMode;
|
import tech.easyflow.ai.enums.DocumentImportMode;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
@@ -435,7 +436,79 @@ public class DocumentImportBatchAppService {
|
|||||||
.orderBy(DocumentImportBatch::getCreated, false)
|
.orderBy(DocumentImportBatch::getCreated, false)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
);
|
);
|
||||||
return batch == null ? null : batchTracker.toStatusResponse(batch);
|
if (batch == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
DocumentImportBatchDtos.StatusResponse response =
|
||||||
|
batchTracker.toStatusResponse(batch);
|
||||||
|
response.setActualCompleted(isAutoBatchActuallyCompleted(batch));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据批次项及其关联文档的真实状态判断失败批次是否已经完成。
|
||||||
|
*
|
||||||
|
* <p>正常完成批次直接返回成功;仅对部分失败或中断批次执行补充查询,
|
||||||
|
* 避免运行中轮询产生额外数据库压力。批次项数量不完整、文档缺失、
|
||||||
|
* 跨知识库或文档仍未完成时均保持失败提示。</p>
|
||||||
|
*
|
||||||
|
* @param batch 自动导入批次
|
||||||
|
* @return 批次关联任务是否已经全部完成
|
||||||
|
*/
|
||||||
|
private boolean isAutoBatchActuallyCompleted(DocumentImportBatch batch) {
|
||||||
|
if (DocumentImportBatchStatus.COMPLETED.name().equals(batch.getStatus())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name().equals(batch.getStatus())
|
||||||
|
&& !DocumentImportBatchStatus.INTERRUPTED.name().equals(batch.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
List<DocumentImportBatchItem> items = itemService.list(
|
||||||
|
QueryWrapper.create()
|
||||||
|
.eq(DocumentImportBatchItem::getBatchId, batch.getId())
|
||||||
|
.orderBy(DocumentImportBatchItem::getId, true)
|
||||||
|
);
|
||||||
|
int totalCount = valueOrZero(batch.getTotalCount());
|
||||||
|
if (items == null || totalCount <= 0 || items.size() != totalCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<BigInteger> unresolvedDocumentIds = new LinkedHashSet<BigInteger>();
|
||||||
|
for (DocumentImportBatchItem item : items) {
|
||||||
|
String itemStatus = item.getStatus();
|
||||||
|
if (DocumentImportBatchItemStatus.COMPLETED.name().equals(itemStatus)
|
||||||
|
|| DocumentImportBatchItemStatus.SKIPPED.name().equals(itemStatus)
|
||||||
|
|| DocumentImportBatchItemStatus.CANCELLED.name().equals(itemStatus)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.getDocumentId() == null
|
||||||
|
|| !batch.getKnowledgeId().equals(item.getKnowledgeId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unresolvedDocumentIds.add(item.getDocumentId());
|
||||||
|
}
|
||||||
|
if (unresolvedDocumentIds.isEmpty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<tech.easyflow.ai.entity.Document> completedDocuments =
|
||||||
|
documentMapper.selectListByQuery(
|
||||||
|
QueryWrapper.create()
|
||||||
|
.select(tech.easyflow.ai.entity.Document::getId)
|
||||||
|
.eq(tech.easyflow.ai.entity.Document::getCollectionId,
|
||||||
|
batch.getKnowledgeId())
|
||||||
|
.eq(tech.easyflow.ai.entity.Document::getProcessStatus,
|
||||||
|
DocumentProcessStatus.COMPLETED.name())
|
||||||
|
.in(tech.easyflow.ai.entity.Document::getId,
|
||||||
|
unresolvedDocumentIds)
|
||||||
|
);
|
||||||
|
if (completedDocuments == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Set<BigInteger> completedDocumentIds = completedDocuments.stream()
|
||||||
|
.map(tech.easyflow.ai.entity.Document::getId)
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
return completedDocumentIds.containsAll(unresolvedDocumentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import tech.easyflow.ai.document.support.DocumentInputStreamSupport;
|
|||||||
import tech.easyflow.ai.document.support.DocumentParseSourceType;
|
import tech.easyflow.ai.document.support.DocumentParseSourceType;
|
||||||
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
import tech.easyflow.common.util.StringUtil;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
@@ -326,20 +327,29 @@ public class DocNodeFileContentExtractor {
|
|||||||
private void copySourceToTemporaryFile(
|
private void copySourceToTemporaryFile(
|
||||||
DocumentSourceRef sourceRef, Path target) throws IOException {
|
DocumentSourceRef sourceRef, Path target) throws IOException {
|
||||||
String filePath = sourceRef.getFilePath();
|
String filePath = sourceRef.getFilePath();
|
||||||
|
boolean managedUpload = StringUtil.hasText(filePath)
|
||||||
|
&& uploadedFileReader != null
|
||||||
|
&& uploadedFileReader.isManagedPathCandidate(filePath);
|
||||||
|
Optional<FileStorageWriteHandle> trustedFile = Optional.empty();
|
||||||
|
if (StringUtil.hasText(filePath)
|
||||||
|
&& isRemoteUrl(filePath)
|
||||||
|
&& !managedUpload) {
|
||||||
|
trustedFile = fileStorageService.resolveTrustedFile(filePath);
|
||||||
|
}
|
||||||
boolean localStorage = StringUtil.hasText(filePath)
|
boolean localStorage = StringUtil.hasText(filePath)
|
||||||
&& (!isRemoteUrl(filePath)
|
&& (!isRemoteUrl(filePath)
|
||||||
|| (uploadedFileReader != null
|
|| managedUpload
|
||||||
&& uploadedFileReader.isManagedPathCandidate(filePath)));
|
|| trustedFile.isPresent());
|
||||||
if (localStorage) {
|
if (localStorage) {
|
||||||
try (IoBulkhead.Permit ignored =
|
try (IoBulkhead.Permit ignored =
|
||||||
IoBulkhead.storage().acquire("storage:document-read");
|
IoBulkhead.storage().acquire("storage:document-read");
|
||||||
InputStream inputStream = openInputStream(sourceRef);
|
InputStream inputStream = openInputStream(sourceRef, trustedFile);
|
||||||
OutputStream outputStream = Files.newOutputStream(target)) {
|
OutputStream outputStream = Files.newOutputStream(target)) {
|
||||||
copy(inputStream, outputStream);
|
copy(inputStream, outputStream);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (InputStream inputStream = openInputStream(sourceRef);
|
try (InputStream inputStream = openInputStream(sourceRef, trustedFile);
|
||||||
OutputStream outputStream = Files.newOutputStream(target)) {
|
OutputStream outputStream = Files.newOutputStream(target)) {
|
||||||
copy(inputStream, outputStream);
|
copy(inputStream, outputStream);
|
||||||
}
|
}
|
||||||
@@ -362,7 +372,17 @@ public class DocNodeFileContentExtractor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private InputStream openInputStream(DocumentSourceRef sourceRef) throws IOException {
|
/**
|
||||||
|
* 按可信受管上传、服务端文件记录、本地路径和普通公网 URL 的顺序打开源流。
|
||||||
|
*
|
||||||
|
* @param sourceRef 文档源
|
||||||
|
* @param trustedFile 已通过服务端记录或存储配置确认的物理读取句柄
|
||||||
|
* @return 受实际字节数限制的输入流
|
||||||
|
* @throws IOException 文件无法安全读取时抛出
|
||||||
|
*/
|
||||||
|
private InputStream openInputStream(
|
||||||
|
DocumentSourceRef sourceRef,
|
||||||
|
Optional<FileStorageWriteHandle> trustedFile) throws IOException {
|
||||||
String filePath = sourceRef.getFilePath();
|
String filePath = sourceRef.getFilePath();
|
||||||
if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
|
if (uploadedFileReader != null && StringUtil.hasText(filePath)) {
|
||||||
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
|
Optional<InputStream> managed = uploadedFileReader.openVerified(filePath);
|
||||||
@@ -372,6 +392,11 @@ public class DocNodeFileContentExtractor {
|
|||||||
FILE_MAX_SINGLE_SIZE);
|
FILE_MAX_SINGLE_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (trustedFile.isPresent()) {
|
||||||
|
return DocumentInputStreamSupport.limit(
|
||||||
|
fileStorageService.readRecoverable(trustedFile.get()),
|
||||||
|
FILE_MAX_SINGLE_SIZE);
|
||||||
|
}
|
||||||
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
|
if (StringUtil.hasText(filePath) && isRemoteUrl(filePath)) {
|
||||||
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
|
return DocumentInputStreamSupport.openRemote(filePath, FILE_MAX_SINGLE_SIZE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
RagScoreNormalizer.normalize(searchDocuments, retrievalMode, reranked);
|
RagScoreNormalizer.normalize(searchDocuments, retrievalMode, reranked);
|
||||||
List<Document> formattedDocuments = formatDocuments(
|
List<Document> formattedDocuments = formatDocuments(
|
||||||
searchDocuments,
|
searchDocuments,
|
||||||
shouldApplyMinSimilarityFilter(retrievalMode, reranked),
|
true,
|
||||||
minSimilarity,
|
minSimilarity,
|
||||||
docRecallMaxNum
|
docRecallMaxNum
|
||||||
);
|
);
|
||||||
@@ -396,10 +396,6 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
return modelRerank.toRerankModel();
|
return modelRerank.toRerankModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldApplyMinSimilarityFilter(RetrievalMode retrievalMode, boolean reranked) {
|
|
||||||
return !reranked && retrievalMode == RetrievalMode.VECTOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析本次查询使用的召回上限,优先采用请求参数,其次回退到知识库默认配置。
|
* 解析本次查询使用的召回上限,优先采用请求参数,其次回退到知识库默认配置。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import tech.easyflow.ai.easyagentsflow.upload.WorkflowApiUploadedFileReader;
|
|||||||
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
import tech.easyflow.ai.document.exception.DocumentParseBridgeException;
|
||||||
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -73,6 +74,24 @@ public class DocumentSourceLoaderTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证服务端已登记的普通附件 URL 在公网地址校验前通过物理句柄直读。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldLoadRecordedInternalStorageUrlBeforeRemoteAddressGuard() {
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/demo.pdf";
|
||||||
|
byte[] body = "recorded-pdf".getBytes(StandardCharsets.UTF_8);
|
||||||
|
DocumentSourceLoader loader = new DocumentSourceLoader(
|
||||||
|
new RecordedFileStorageService(fileUrl, body));
|
||||||
|
DocumentSourceRef sourceRef = new DocumentSourceRef();
|
||||||
|
sourceRef.setFileName("demo.pdf");
|
||||||
|
sourceRef.setFilePath(fileUrl);
|
||||||
|
|
||||||
|
LoadedDocumentSource loadedSource = loader.load(sourceRef);
|
||||||
|
|
||||||
|
Assert.assertArrayEquals(body, loadedSource.getContentBytes());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。
|
* 验证已通过上传记录校验的内网存储 URL 会走恢复句柄读取。
|
||||||
*
|
*
|
||||||
@@ -217,4 +236,42 @@ public class DocumentSourceLoaderTest {
|
|||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
|
||||||
|
*/
|
||||||
|
private static class RecordedFileStorageService
|
||||||
|
extends FailingFileStorageService {
|
||||||
|
/** 允许解析的精确 URL。 */
|
||||||
|
private final String recordedUrl;
|
||||||
|
/** 固定文件内容。 */
|
||||||
|
private final byte[] content;
|
||||||
|
/** 固定可信读取句柄。 */
|
||||||
|
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||||
|
"recorded", "", "/storage", "attachment", "demo.pdf");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务端记录存储替身。
|
||||||
|
*
|
||||||
|
* @param recordedUrl 允许解析的精确 URL
|
||||||
|
* @param content 固定文件内容
|
||||||
|
*/
|
||||||
|
private RecordedFileStorageService(String recordedUrl, byte[] content) {
|
||||||
|
this.recordedUrl = recordedUrl;
|
||||||
|
this.content = content.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
|
||||||
|
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
|
||||||
|
Assert.assertSame(handle, requestedHandle);
|
||||||
|
return new ByteArrayInputStream(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import tech.easyflow.ai.enums.DocumentImportBatchItemStage;
|
|||||||
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchItemStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
import tech.easyflow.ai.enums.DocumentImportBatchStatus;
|
||||||
import tech.easyflow.ai.enums.DocumentImportMode;
|
import tech.easyflow.ai.enums.DocumentImportMode;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchItemMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
import tech.easyflow.ai.mapper.DocumentImportBatchMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
@@ -47,6 +48,89 @@ import java.util.function.BooleanSupplier;
|
|||||||
*/
|
*/
|
||||||
public class DocumentImportBatchAppServiceTest {
|
public class DocumentImportBatchAppServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证历史失败批次关联文档均已完成时返回真实完成标记。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void latestAutoBatchShouldDetectActuallyCompletedDocuments() {
|
||||||
|
TestContext context = createContext();
|
||||||
|
DocumentImportBatch batch = context.batchService.getOne(
|
||||||
|
QueryWrapper.create()
|
||||||
|
);
|
||||||
|
batch.setImportMode(DocumentImportMode.AUTO.name());
|
||||||
|
batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
|
||||||
|
batch.setTotalCount(2);
|
||||||
|
|
||||||
|
DocumentImportBatchItem completed = uploadedItem(
|
||||||
|
BigInteger.valueOf(11), batch.getId()
|
||||||
|
);
|
||||||
|
completed.setStatus(DocumentImportBatchItemStatus.COMPLETED.name());
|
||||||
|
completed.setDocumentId(BigInteger.valueOf(101));
|
||||||
|
DocumentImportBatchItem staleFailed = uploadedItem(
|
||||||
|
BigInteger.valueOf(12), batch.getId()
|
||||||
|
);
|
||||||
|
staleFailed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
|
||||||
|
staleFailed.setDocumentId(BigInteger.valueOf(102));
|
||||||
|
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
|
||||||
|
.thenReturn(List.of(completed, staleFailed));
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.Document recovered =
|
||||||
|
new tech.easyflow.ai.entity.Document();
|
||||||
|
recovered.setId(staleFailed.getDocumentId());
|
||||||
|
recovered.setCollectionId(batch.getKnowledgeId());
|
||||||
|
recovered.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
|
||||||
|
Mockito.when(context.documentMapper.selectListByQuery(Mockito.any()))
|
||||||
|
.thenReturn(List.of(recovered));
|
||||||
|
|
||||||
|
DocumentImportBatchDtos.StatusResponse batchResponse =
|
||||||
|
new DocumentImportBatchDtos.StatusResponse();
|
||||||
|
batchResponse.setStatus(batch.getStatus());
|
||||||
|
Mockito.when(context.batchTracker.toStatusResponse(batch))
|
||||||
|
.thenReturn(batchResponse);
|
||||||
|
|
||||||
|
DocumentImportBatchDtos.StatusResponse response =
|
||||||
|
context.service.getLatestAutoBatch(batch.getKnowledgeId());
|
||||||
|
|
||||||
|
Assert.assertTrue(response.getActualCompleted());
|
||||||
|
Mockito.verify(context.documentMapper)
|
||||||
|
.selectListByQuery(Mockito.any(QueryWrapper.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证失败项关联文档仍未完成时继续保留批次提示。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void latestAutoBatchShouldKeepIncompleteFailureVisible() {
|
||||||
|
TestContext context = createContext();
|
||||||
|
DocumentImportBatch batch = context.batchService.getOne(
|
||||||
|
QueryWrapper.create()
|
||||||
|
);
|
||||||
|
batch.setImportMode(DocumentImportMode.AUTO.name());
|
||||||
|
batch.setStatus(DocumentImportBatchStatus.PARTIAL_SUCCEEDED.name());
|
||||||
|
batch.setTotalCount(1);
|
||||||
|
|
||||||
|
DocumentImportBatchItem failed = uploadedItem(
|
||||||
|
BigInteger.valueOf(13), batch.getId()
|
||||||
|
);
|
||||||
|
failed.setStatus(DocumentImportBatchItemStatus.FAILED.name());
|
||||||
|
failed.setDocumentId(BigInteger.valueOf(103));
|
||||||
|
Mockito.when(context.itemService.list(Mockito.any(QueryWrapper.class)))
|
||||||
|
.thenReturn(List.of(failed));
|
||||||
|
Mockito.when(context.documentMapper.selectListByQuery(Mockito.any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
DocumentImportBatchDtos.StatusResponse batchResponse =
|
||||||
|
new DocumentImportBatchDtos.StatusResponse();
|
||||||
|
batchResponse.setStatus(batch.getStatus());
|
||||||
|
Mockito.when(context.batchTracker.toStatusResponse(batch))
|
||||||
|
.thenReturn(batchResponse);
|
||||||
|
|
||||||
|
DocumentImportBatchDtos.StatusResponse response =
|
||||||
|
context.service.getLatestAutoBatch(batch.getKnowledgeId());
|
||||||
|
|
||||||
|
Assert.assertFalse(response.getActualCompleted());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证上传领取通过单条多表更新同步刷新批次进度时间。
|
* 验证上传领取通过单条多表更新同步刷新批次进度时间。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import tech.easyflow.ai.document.model.DocumentParsedResult;
|
|||||||
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
import tech.easyflow.ai.document.model.DocumentSourceRef;
|
||||||
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
import tech.easyflow.ai.document.service.DocumentParseBridgeService;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
|
import tech.easyflow.common.filestorage.FileStorageWriteHandle;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
@@ -203,6 +204,27 @@ public class DocNodeFileContentExtractorTest {
|
|||||||
Assert.assertNull(bridgeService.lastSource);
|
Assert.assertNull(bridgeService.lastSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证默认读取器也能通过服务端文件记录读取内部附件 URL。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReadRecordedInternalUrlForUnsupportedType() {
|
||||||
|
RecordingDocumentParseBridgeService bridgeService = new RecordingDocumentParseBridgeService();
|
||||||
|
String fileUrl = "http://127.0.0.1:39000/easyflow/attachment/1/2026/8/26/note.txt";
|
||||||
|
DocNodeFileContentExtractor extractor = new DocNodeFileContentExtractor(
|
||||||
|
bridgeService,
|
||||||
|
new RecordedFileStorageService(fileUrl, "recorded text"),
|
||||||
|
new ReadingReaderManager());
|
||||||
|
|
||||||
|
String content = extractor.extract(buildFileValue(
|
||||||
|
"note.txt",
|
||||||
|
fileUrl,
|
||||||
|
"text/plain"));
|
||||||
|
|
||||||
|
Assert.assertEquals("recorded text", content);
|
||||||
|
Assert.assertNull(bridgeService.lastSource);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
|
* 验证受管上传 URL 的非桥接文件通过记录校验后走内部存储读取。
|
||||||
*
|
*
|
||||||
@@ -574,4 +596,42 @@ public class DocNodeFileContentExtractorTest {
|
|||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅允许通过服务端记录句柄读取内容的存储测试替身。
|
||||||
|
*/
|
||||||
|
private static class RecordedFileStorageService
|
||||||
|
extends FailingFileStorageService {
|
||||||
|
/** 允许解析的精确 URL。 */
|
||||||
|
private final String recordedUrl;
|
||||||
|
/** 固定内容。 */
|
||||||
|
private final byte[] content;
|
||||||
|
/** 固定可信读取句柄。 */
|
||||||
|
private final FileStorageWriteHandle handle = new FileStorageWriteHandle(
|
||||||
|
"recorded", "", "/storage", "attachment", "note.txt");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建服务端记录存储替身。
|
||||||
|
*
|
||||||
|
* @param recordedUrl 允许解析的精确 URL
|
||||||
|
* @param content 固定文本内容
|
||||||
|
*/
|
||||||
|
private RecordedFileStorageService(String recordedUrl, String content) {
|
||||||
|
this.recordedUrl = recordedUrl;
|
||||||
|
this.content = content.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public Optional<FileStorageWriteHandle> resolveTrustedFile(String reference) {
|
||||||
|
return recordedUrl.equals(reference) ? Optional.of(handle) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
@Override
|
||||||
|
public InputStream readRecoverable(FileStorageWriteHandle requestedHandle) {
|
||||||
|
Assert.assertSame(handle, requestedHandle);
|
||||||
|
return new ByteArrayInputStream(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,28 @@ import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOL
|
|||||||
*/
|
*/
|
||||||
public class DocumentCollectionServiceImplTest {
|
public class DocumentCollectionServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证最终相关度阈值会过滤所有已统一到零到一范围的检索结果。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void formatDocumentsShouldApplyFinalScoreThreshold() {
|
||||||
|
Document lowScore = buildHit(BigInteger.ONE, 0.49D);
|
||||||
|
Document thresholdScore = buildHit(BigInteger.TWO, 0.5D);
|
||||||
|
Document highScore = buildHit(BigInteger.valueOf(3), 0.9D);
|
||||||
|
DocumentCollectionServiceImpl service = new DocumentCollectionServiceImpl();
|
||||||
|
|
||||||
|
List<Document> result = service.formatDocuments(
|
||||||
|
List.of(lowScore, thresholdScore, highScore),
|
||||||
|
true,
|
||||||
|
0.5F,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertEquals(2, result.size());
|
||||||
|
Assert.assertEquals(highScore.getId(), result.get(0).getId());
|
||||||
|
Assert.assertEquals(thresholdScore.getId(), result.get(1).getId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证检索结果会在重排前过滤掉未完成文档,避免高分进行中文档挤占最终名额。
|
* 验证检索结果会在重排前过滤掉未完成文档,避免高分进行中文档挤占最终名额。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ public class SkillToolReferenceProviderImpl implements SkillToolReferenceProvide
|
|||||||
ids.add(skill.getId());
|
ids.add(skill.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
List<OfflineImpactBindingVo> result = new ArrayList<>();
|
List<OfflineImpactBindingVo> result = new ArrayList<>();
|
||||||
for (Skill skill : skillService.listByIds(ids)) {
|
for (Skill skill : skillService.listByIds(ids)) {
|
||||||
OfflineImpactBindingVo item = new OfflineImpactBindingVo();
|
OfflineImpactBindingVo item = new OfflineImpactBindingVo();
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public class SkillToolReferenceProviderImplTest {
|
|||||||
skillService, bindingService);
|
skillService, bindingService);
|
||||||
|
|
||||||
Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty());
|
Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty());
|
||||||
|
Mockito.verify(skillService, Mockito.never()).listByIds(Mockito.anyCollection());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"visibilityScopePublic": "Public",
|
"visibilityScopePublic": "Public",
|
||||||
"visibilityScopePublicDesc": "Available to internal users matched by category",
|
"visibilityScopePublicDesc": "Available to internal users matched by category",
|
||||||
"params": "Params",
|
"params": "Params",
|
||||||
|
"runInputForm": "Input form",
|
||||||
"steps": "Steps",
|
"steps": "Steps",
|
||||||
"result": "Result",
|
"result": "Result",
|
||||||
"confirm": "For contents to be confirmed, please confirm first!",
|
"confirm": "For contents to be confirmed, please confirm first!",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"visibilityScopePublic": "公开",
|
"visibilityScopePublic": "公开",
|
||||||
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
|
"visibilityScopePublicDesc": "分类命中的内部用户可访问",
|
||||||
"params": "执行参数",
|
"params": "执行参数",
|
||||||
|
"runInputForm": "输入表单",
|
||||||
"steps": "执行步骤",
|
"steps": "执行步骤",
|
||||||
"result": "执行结果",
|
"result": "执行结果",
|
||||||
"confirm": "有待确认的内容,请先确认!",
|
"confirm": "有待确认的内容,请先确认!",
|
||||||
|
|||||||
@@ -181,6 +181,57 @@ describe('agentChatRuntimeManager', () => {
|
|||||||
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('输入已确认后将模型错误转换为可重试的用户提示', async () => {
|
||||||
|
let resolveRun: (() => void) | undefined;
|
||||||
|
let runOptions: EasyFlowAguiRunOptions | undefined;
|
||||||
|
aguiMocks.run.mockImplementation((options: EasyFlowAguiRunOptions) => {
|
||||||
|
runOptions = options;
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
resolveRun = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
useUserStore().setUserInfo({
|
||||||
|
avatar: '',
|
||||||
|
id: 'retry-user',
|
||||||
|
loginName: 'retry-user',
|
||||||
|
nickname: '重试用户',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentChatRuntimeManager.start({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
prompt: '介绍文档',
|
||||||
|
sessionId: 'retry-session',
|
||||||
|
});
|
||||||
|
runOptions?.onEvent({
|
||||||
|
name: easyFlowAguiCustomEvent.inputAccepted,
|
||||||
|
type: EventType.CUSTOM,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
|
runOptions?.onEvent({
|
||||||
|
code: 'MODEL_ERROR',
|
||||||
|
message: 'Retries exhausted: 2/2',
|
||||||
|
runId: 'run-test',
|
||||||
|
threadId: 'retry-session',
|
||||||
|
type: EventType.RUN_ERROR,
|
||||||
|
});
|
||||||
|
resolveRun?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const snapshot = agentChatRuntimeManager.getSnapshot('retry-session');
|
||||||
|
expect(snapshot).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: '模型连接异常',
|
||||||
|
retryContextReady: true,
|
||||||
|
terminalOutcome: 'failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(snapshot?.items.find((item) => item.type === 'error')).toEqual(
|
||||||
|
expect.objectContaining({ message: '模型连接异常' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
|
it('刷新恢复已确认输入时重新触发草稿清理回调', async () => {
|
||||||
const account = {
|
const account = {
|
||||||
avatar: '',
|
avatar: '',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface RuntimeSessionState {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
projectionToolArgs: Record<string, string>;
|
projectionToolArgs: Record<string, string>;
|
||||||
projectionToolNames: Record<string, string>;
|
projectionToolNames: Record<string, string>;
|
||||||
|
retryContextReady: boolean;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
roundId: string;
|
roundId: string;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
@@ -65,6 +66,7 @@ interface StoredRuntimeSession {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
projectionToolArgs?: Record<string, string>;
|
projectionToolArgs?: Record<string, string>;
|
||||||
projectionToolNames?: Record<string, string>;
|
projectionToolNames?: Record<string, string>;
|
||||||
|
retryContextReady?: boolean;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
roundId: string;
|
roundId: string;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
@@ -86,10 +88,12 @@ interface StartOptions {
|
|||||||
images?: ChatImageAttachment[];
|
images?: ChatImageAttachment[];
|
||||||
onInputAccepted?: () => Promise<void> | void;
|
onInputAccepted?: () => Promise<void> | void;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
retryContextReady?: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_VERSION = 5;
|
const STORAGE_VERSION = 6;
|
||||||
|
const MODEL_CONNECTION_ERROR_MESSAGE = '模型连接异常';
|
||||||
const STREAM_NOTIFY_INTERVAL_MS = 50;
|
const STREAM_NOTIFY_INTERVAL_MS = 50;
|
||||||
const STREAM_PERSIST_INTERVAL_MS = 300;
|
const STREAM_PERSIST_INTERVAL_MS = 300;
|
||||||
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
|
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
|
||||||
@@ -177,6 +181,7 @@ function persistSession(state: RuntimeSessionState) {
|
|||||||
prompt: state.prompt,
|
prompt: state.prompt,
|
||||||
projectionToolArgs: state.projectionToolArgs,
|
projectionToolArgs: state.projectionToolArgs,
|
||||||
projectionToolNames: state.projectionToolNames,
|
projectionToolNames: state.projectionToolNames,
|
||||||
|
retryContextReady: state.retryContextReady,
|
||||||
runId: state.runId,
|
runId: state.runId,
|
||||||
roundId: state.roundId,
|
roundId: state.roundId,
|
||||||
sending: state.sending,
|
sending: state.sending,
|
||||||
@@ -298,7 +303,7 @@ function restoreSession(identity: string, sessionId: string) {
|
|||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as StoredRuntimeSession;
|
const parsed = JSON.parse(raw) as StoredRuntimeSession;
|
||||||
if (
|
if (
|
||||||
![3, 4, STORAGE_VERSION].includes(parsed.version) ||
|
![3, 4, 5, STORAGE_VERSION].includes(parsed.version) ||
|
||||||
parsed.sessionId !== sessionId
|
parsed.sessionId !== sessionId
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -324,6 +329,7 @@ function restoreSession(identity: string, sessionId: string) {
|
|||||||
typeof parsed.projectionToolNames === 'object'
|
typeof parsed.projectionToolNames === 'object'
|
||||||
? parsed.projectionToolNames
|
? parsed.projectionToolNames
|
||||||
: {},
|
: {},
|
||||||
|
retryContextReady: Boolean(parsed.retryContextReady),
|
||||||
runId: parsed.runId,
|
runId: parsed.runId,
|
||||||
roundId: parsed.roundId,
|
roundId: parsed.roundId,
|
||||||
sending: Boolean(parsed.sending && parsed.runId),
|
sending: Boolean(parsed.sending && parsed.runId),
|
||||||
@@ -386,6 +392,7 @@ function acceptInput(
|
|||||||
) {
|
) {
|
||||||
replaceAcceptedAttachments(state.items, state.roundId, payload);
|
replaceAcceptedAttachments(state.items, state.roundId, payload);
|
||||||
state.inputAccepted = true;
|
state.inputAccepted = true;
|
||||||
|
state.retryContextReady = true;
|
||||||
persistSession(state);
|
persistSession(state);
|
||||||
notifyInputAccepted(state);
|
notifyInputAccepted(state);
|
||||||
}
|
}
|
||||||
@@ -563,7 +570,9 @@ function finishRuntimeSuccess(state: RuntimeSessionState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
|
function finishRuntimeFailure(state: RuntimeSessionState, error: unknown) {
|
||||||
state.error = errorMessage(error);
|
state.error = state.retryContextReady
|
||||||
|
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||||
|
: errorMessage(error);
|
||||||
state.sending = false;
|
state.sending = false;
|
||||||
state.completed = true;
|
state.completed = true;
|
||||||
const last = state.items[state.items.length - 1];
|
const last = state.items[state.items.length - 1];
|
||||||
@@ -664,7 +673,9 @@ export const agentChatRuntimeManager = {
|
|||||||
event.type === EventType.RUN_ERROR &&
|
event.type === EventType.RUN_ERROR &&
|
||||||
event.code !== 'RUN_CANCELLED'
|
event.code !== 'RUN_CANCELLED'
|
||||||
) {
|
) {
|
||||||
current.error = event.message || '发送失败,请稍后再试';
|
current.error = current.retryContextReady
|
||||||
|
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||||
|
: event.message || '发送失败,请稍后再试';
|
||||||
}
|
}
|
||||||
observeTerminalEvent(current, event.type);
|
observeTerminalEvent(current, event.type);
|
||||||
applyAguiEventToTimeline(
|
applyAguiEventToTimeline(
|
||||||
@@ -674,6 +685,9 @@ export const agentChatRuntimeManager = {
|
|||||||
onInputAccepted(payload) {
|
onInputAccepted(payload) {
|
||||||
acceptInput(current, payload);
|
acceptInput(current, payload);
|
||||||
},
|
},
|
||||||
|
runErrorMessage: current.retryContextReady
|
||||||
|
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||||
|
: undefined,
|
||||||
roundId: current.roundId,
|
roundId: current.roundId,
|
||||||
startedAt: current.startedAt,
|
startedAt: current.startedAt,
|
||||||
},
|
},
|
||||||
@@ -757,6 +771,7 @@ export const agentChatRuntimeManager = {
|
|||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
projectionToolArgs: {},
|
projectionToolArgs: {},
|
||||||
projectionToolNames: {},
|
projectionToolNames: {},
|
||||||
|
retryContextReady: Boolean(options.retryContextReady),
|
||||||
runId,
|
runId,
|
||||||
roundId,
|
roundId,
|
||||||
sending: true,
|
sending: true,
|
||||||
@@ -794,7 +809,9 @@ export const agentChatRuntimeManager = {
|
|||||||
event.type === EventType.RUN_ERROR &&
|
event.type === EventType.RUN_ERROR &&
|
||||||
event.code !== 'RUN_CANCELLED'
|
event.code !== 'RUN_CANCELLED'
|
||||||
) {
|
) {
|
||||||
current.error = event.message || '发送失败,请稍后再试';
|
current.error = current.retryContextReady
|
||||||
|
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||||
|
: event.message || '发送失败,请稍后再试';
|
||||||
}
|
}
|
||||||
observeTerminalEvent(current, event.type);
|
observeTerminalEvent(current, event.type);
|
||||||
applyAguiEventToTimeline(
|
applyAguiEventToTimeline(
|
||||||
@@ -804,6 +821,9 @@ export const agentChatRuntimeManager = {
|
|||||||
onInputAccepted(payload) {
|
onInputAccepted(payload) {
|
||||||
acceptInput(current, payload);
|
acceptInput(current, payload);
|
||||||
},
|
},
|
||||||
|
runErrorMessage: current.retryContextReady
|
||||||
|
? MODEL_CONNECTION_ERROR_MESSAGE
|
||||||
|
: undefined,
|
||||||
roundId,
|
roundId,
|
||||||
startedAt,
|
startedAt,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ChatDocumentAttachment,
|
ChatDocumentAttachment,
|
||||||
ChatImageAttachment,
|
ChatImageAttachment,
|
||||||
|
ChatTimelineErrorItem,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
@@ -108,6 +109,7 @@ const loadingKnowledges = ref(false);
|
|||||||
const savingExtraKnowledges = ref(false);
|
const savingExtraKnowledges = ref(false);
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const runtimeRunning = ref(false);
|
const runtimeRunning = ref(false);
|
||||||
|
const retryableErrorRoundId = ref('');
|
||||||
const approvalLoadingKey = ref('');
|
const approvalLoadingKey = ref('');
|
||||||
const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
|
const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
|
||||||
const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
|
const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
|
||||||
@@ -463,12 +465,19 @@ function syncRuntimeSnapshot(sessionId = currentSessionId.value) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
|
retryableErrorRoundId.value = '';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
currentSessionId.value = snapshot.sessionId;
|
currentSessionId.value = snapshot.sessionId;
|
||||||
selectedAgentId.value = String(snapshot.agentId);
|
selectedAgentId.value = String(snapshot.agentId);
|
||||||
timelineItems.value = snapshot.items;
|
timelineItems.value = snapshot.items;
|
||||||
sending.value = snapshot.sending;
|
sending.value = snapshot.sending;
|
||||||
|
retryableErrorRoundId.value =
|
||||||
|
snapshot.terminalOutcome === 'failed' &&
|
||||||
|
snapshot.retryContextReady &&
|
||||||
|
!snapshot.sending
|
||||||
|
? snapshot.roundId
|
||||||
|
: '';
|
||||||
maybeRefreshCompletedRuntimeSession(snapshot);
|
maybeRefreshCompletedRuntimeSession(snapshot);
|
||||||
if (snapshot.prompt && !currentSession.value) {
|
if (snapshot.prompt && !currentSession.value) {
|
||||||
upsertSessionRecord(
|
upsertSessionRecord(
|
||||||
@@ -492,15 +501,20 @@ async function loadConversation(sessionId: string) {
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
timelineItems.value = [];
|
timelineItems.value = [];
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
|
retryableErrorRoundId.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
|
const runtimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
|
||||||
if (runtimeSnapshot?.sending) {
|
if (
|
||||||
|
runtimeSnapshot?.sending ||
|
||||||
|
runtimeSnapshot?.terminalOutcome === 'failed'
|
||||||
|
) {
|
||||||
syncRuntimeSnapshot(sessionId);
|
syncRuntimeSnapshot(sessionId);
|
||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingConversation.value = true;
|
loadingConversation.value = true;
|
||||||
|
retryableErrorRoundId.value = '';
|
||||||
try {
|
try {
|
||||||
const detailRes = await getAgentSession(sessionId);
|
const detailRes = await getAgentSession(sessionId);
|
||||||
const res = await getAgentConversation(sessionId);
|
const res = await getAgentConversation(sessionId);
|
||||||
@@ -660,12 +674,25 @@ function buildCapabilities() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendContent(rawContent: string) {
|
interface SendContentOptions {
|
||||||
|
includeComposer?: boolean;
|
||||||
|
retryContextReady?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendContent(
|
||||||
|
rawContent: string,
|
||||||
|
options: SendContentOptions = {},
|
||||||
|
) {
|
||||||
const content = rawContent.trim();
|
const content = rawContent.trim();
|
||||||
|
const includeComposer = options.includeComposer !== false;
|
||||||
|
const readyImageCount = includeComposer
|
||||||
|
? composer.images.readyItems.value.length
|
||||||
|
: 0;
|
||||||
|
const readyDocumentCount = includeComposer
|
||||||
|
? composer.documents.readyItems.value.length
|
||||||
|
: 0;
|
||||||
if (
|
if (
|
||||||
(!content &&
|
(!content && readyImageCount === 0 && readyDocumentCount === 0) ||
|
||||||
composer.images.readyItems.value.length === 0 &&
|
|
||||||
composer.documents.readyItems.value.length === 0) ||
|
|
||||||
!selectedAgentId.value ||
|
!selectedAgentId.value ||
|
||||||
sending.value
|
sending.value
|
||||||
) {
|
) {
|
||||||
@@ -675,44 +702,54 @@ async function sendContent(rawContent: string) {
|
|||||||
ElMessage.warning('当前回复完成后再发送新消息');
|
ElMessage.warning('当前回复完成后再发送新消息');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (composer.images.uploading.value) {
|
if (includeComposer && composer.images.uploading.value) {
|
||||||
ElMessage.warning('图片上传完成后再发送');
|
ElMessage.warning('图片上传完成后再发送');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (composer.documents.processing.value) {
|
if (includeComposer && composer.documents.processing.value) {
|
||||||
ElMessage.warning('文档读取完成后再发送');
|
ElMessage.warning('文档读取完成后再发送');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const failedImage = composer.images.items.value.find(
|
const failedImage = includeComposer
|
||||||
(item) => item.status === 'error',
|
? composer.images.items.value.find((item) => item.status === 'error')
|
||||||
);
|
: undefined;
|
||||||
if (failedImage) {
|
if (failedImage) {
|
||||||
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const failedDocument = composer.documents.items.value.find(
|
const failedDocument = includeComposer
|
||||||
(item) => item.status === 'error',
|
? composer.documents.items.value.find((item) => item.status === 'error')
|
||||||
);
|
: undefined;
|
||||||
if (failedDocument) {
|
if (failedDocument) {
|
||||||
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
|
ElMessage.error(failedDocument.error || '请处理读取失败的文档');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (includeComposer) {
|
||||||
await composer.flush();
|
await composer.flush();
|
||||||
|
}
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
|
retryableErrorRoundId.value = '';
|
||||||
try {
|
try {
|
||||||
const sessionId = await agentChatRuntimeManager.start({
|
const sessionId = await agentChatRuntimeManager.start({
|
||||||
agentId: selectedAgentId.value,
|
agentId: selectedAgentId.value,
|
||||||
agentName: selectedAgent.value?.name,
|
agentName: selectedAgent.value?.name,
|
||||||
baseItems: timelineItems.value,
|
baseItems: timelineItems.value,
|
||||||
capabilities: buildCapabilities(),
|
capabilities: buildCapabilities(),
|
||||||
documentUploadIds: composer.documents.uploadIds.value,
|
documentUploadIds: includeComposer
|
||||||
documents: composer.documents.readyItems.value.map((item) => ({
|
? composer.documents.uploadIds.value
|
||||||
...item,
|
: undefined,
|
||||||
})),
|
documents: includeComposer
|
||||||
imageUploadIds: composer.images.uploadIds.value,
|
? composer.documents.readyItems.value.map((item) => ({ ...item }))
|
||||||
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
: undefined,
|
||||||
onInputAccepted: markComposerInputAccepted,
|
imageUploadIds: includeComposer
|
||||||
|
? composer.images.uploadIds.value
|
||||||
|
: undefined,
|
||||||
|
images: includeComposer
|
||||||
|
? composer.images.readyItems.value.map((item) => ({ ...item }))
|
||||||
|
: undefined,
|
||||||
|
onInputAccepted: includeComposer ? markComposerInputAccepted : undefined,
|
||||||
prompt: content,
|
prompt: content,
|
||||||
|
retryContextReady: Boolean(options.retryContextReady),
|
||||||
sessionId: composer.sessionId.value,
|
sessionId: composer.sessionId.value,
|
||||||
});
|
});
|
||||||
await bindCreatedSession(sessionId, content);
|
await bindCreatedSession(sessionId, content);
|
||||||
@@ -725,6 +762,24 @@ async function sendContent(rawContent: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelErrorAction(item: ChatTimelineErrorItem) {
|
||||||
|
return item.roundId === retryableErrorRoundId.value ? '请重试' : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModelErrorRetry(item: ChatTimelineErrorItem) {
|
||||||
|
if (
|
||||||
|
item.roundId !== retryableErrorRoundId.value ||
|
||||||
|
sending.value ||
|
||||||
|
runtimeRunning.value
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sendContent('继续', {
|
||||||
|
includeComposer: false,
|
||||||
|
retryContextReady: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
await sendContent(promptText.value);
|
await sendContent(promptText.value);
|
||||||
}
|
}
|
||||||
@@ -1266,12 +1321,15 @@ onBeforeUnmount(() => {
|
|||||||
:artifact-loader="loadCurrentAgentArtifact"
|
:artifact-loader="loadCurrentAgentArtifact"
|
||||||
:items="timelineItems"
|
:items="timelineItems"
|
||||||
:document-loader="loadAgentChatDocument"
|
:document-loader="loadAgentChatDocument"
|
||||||
|
:error-action="modelErrorAction"
|
||||||
|
:error-action-disabled="sending || runtimeRunning"
|
||||||
:image-loader="loadAgentChatImage"
|
:image-loader="loadAgentChatImage"
|
||||||
empty-text="选择智能体后开始对话"
|
empty-text="选择智能体后开始对话"
|
||||||
:approval-loading="Boolean(approvalLoadingKey)"
|
:approval-loading="Boolean(approvalLoadingKey)"
|
||||||
:copy-action="handleCopyMessage"
|
:copy-action="handleCopyMessage"
|
||||||
:copyable="canCopyMessage"
|
:copyable="canCopyMessage"
|
||||||
@approve="handleApprove"
|
@approve="handleApprove"
|
||||||
|
@error-action="handleModelErrorRetry"
|
||||||
@reject="handleReject"
|
@reject="handleReject"
|
||||||
@select-next-variant="() => undefined"
|
@select-next-variant="() => undefined"
|
||||||
@select-previous-variant="() => undefined"
|
@select-previous-variant="() => undefined"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('documentImportBatchStatus', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当前页面跟踪的批次完成后继续展示结果', async () => {
|
it('当前页面跟踪的批次完成后移除提示', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
apiMocks.get
|
apiMocks.get
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -73,10 +73,62 @@ describe('documentImportBatchStatus', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(3000);
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('.batch-status').exists()).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败批次关联任务实际完成后不再展示提示', async () => {
|
||||||
|
apiMocks.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
...createBatch('RUNNING', 0),
|
||||||
|
actualCompleted: true,
|
||||||
|
failedCount: 2,
|
||||||
|
pendingCount: 0,
|
||||||
|
status: 'PARTIAL_SUCCEEDED',
|
||||||
|
},
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(DocumentImportBatchStatus, {
|
||||||
|
props: { knowledgeId: 'knowledge-1' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('.batch-status').exists()).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('失败批次低频复查后在关联任务完成时移除提示', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const failedBatch = {
|
||||||
|
...createBatch('RUNNING', 0),
|
||||||
|
actualCompleted: false,
|
||||||
|
failedCount: 2,
|
||||||
|
pendingCount: 0,
|
||||||
|
status: 'PARTIAL_SUCCEEDED',
|
||||||
|
};
|
||||||
|
apiMocks.get
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: failedBatch,
|
||||||
|
errorCode: 0,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { ...failedBatch, actualCompleted: true },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mount(DocumentImportBatchStatus, {
|
||||||
|
props: { knowledgeId: 'knowledge-1' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
expect(wrapper.find('.batch-status').exists()).toBe(true);
|
expect(wrapper.find('.batch-status').exists()).toBe(true);
|
||||||
expect(wrapper.text()).toContain(
|
|
||||||
'documentCollection.importDoc.batchCompleted',
|
await vi.advanceTimersByTimeAsync(15_000);
|
||||||
);
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(apiMocks.get).toHaveBeenCalledTimes(2);
|
||||||
|
expect(wrapper.find('.batch-status').exists()).toBe(false);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -239,9 +291,7 @@ describe('documentImportBatchStatus', () => {
|
|||||||
|
|
||||||
expect(apiMocks.get).toHaveBeenCalledTimes(3);
|
expect(apiMocks.get).toHaveBeenCalledTimes(3);
|
||||||
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
|
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.find('.batch-status').exists()).toBe(false);
|
||||||
'documentCollection.importDoc.batchCompleted',
|
|
||||||
);
|
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ElAlert, ElButton, ElProgress } from 'element-plus';
|
|||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
interface BatchStatus {
|
interface BatchStatus {
|
||||||
|
actualCompleted?: boolean;
|
||||||
batchId: string;
|
batchId: string;
|
||||||
cancelledCount?: number;
|
cancelledCount?: number;
|
||||||
completedCount: number;
|
completedCount: number;
|
||||||
@@ -53,6 +54,7 @@ let pollTimer: null | ReturnType<typeof setTimeout> = null;
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let refreshGeneration = 0;
|
let refreshGeneration = 0;
|
||||||
let pollDelayMs = 3000;
|
let pollDelayMs = 3000;
|
||||||
|
const TERMINAL_RECHECK_DELAY_MS = 15_000;
|
||||||
|
|
||||||
const canContinue = computed(
|
const canContinue = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -122,7 +124,7 @@ const interruptMetadata = computed(() => {
|
|||||||
return details.join(' · ');
|
return details.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refresh(hideCompletedOnRestore = false) {
|
async function refresh() {
|
||||||
if (!props.knowledgeId) return;
|
if (!props.knowledgeId) return;
|
||||||
const currentGeneration = ++refreshGeneration;
|
const currentGeneration = ++refreshGeneration;
|
||||||
refreshing.value = true;
|
refreshing.value = true;
|
||||||
@@ -142,10 +144,9 @@ async function refresh(hideCompletedOnRestore = false) {
|
|||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
pollDelayMs = 3000;
|
pollDelayMs = 3000;
|
||||||
const restoredBatch = response.data || undefined;
|
const restoredBatch = response.data || undefined;
|
||||||
batch.value =
|
const completed =
|
||||||
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
|
restoredBatch?.status === 'COMPLETED' || restoredBatch?.actualCompleted;
|
||||||
? undefined
|
batch.value = completed ? undefined : restoredBatch;
|
||||||
: restoredBatch;
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!disposed && currentGeneration === refreshGeneration) {
|
if (!disposed && currentGeneration === refreshGeneration) {
|
||||||
loadError.value = $t(
|
loadError.value = $t(
|
||||||
@@ -175,13 +176,16 @@ function schedulePoll() {
|
|||||||
if (
|
if (
|
||||||
!batch.value ||
|
!batch.value ||
|
||||||
batch.value.status === 'COMPLETED' ||
|
batch.value.status === 'COMPLETED' ||
|
||||||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
|
|
||||||
batch.value.status === 'INTERRUPTED' ||
|
|
||||||
batch.value.status === 'CANCELLED'
|
batch.value.status === 'CANCELLED'
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pollTimer = setTimeout(refresh, 3000);
|
const delay =
|
||||||
|
batch.value.status === 'PARTIAL_SUCCEEDED' ||
|
||||||
|
batch.value.status === 'INTERRUPTED'
|
||||||
|
? TERMINAL_RECHECK_DELAY_MS
|
||||||
|
: 3000;
|
||||||
|
pollTimer = setTimeout(refresh, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function continueBatch() {
|
async function continueBatch() {
|
||||||
@@ -204,7 +208,7 @@ async function continueBatch() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
disposed = false;
|
disposed = false;
|
||||||
refresh(true);
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -222,7 +226,7 @@ watch(
|
|||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
pollDelayMs = 3000;
|
pollDelayMs = 3000;
|
||||||
}
|
}
|
||||||
refresh(knowledgeChanged);
|
refresh();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
multiple: {
|
multiple: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -37,13 +41,20 @@ const pageUrl = computed(() => {
|
|||||||
: `${baseUrl}?resourceType=${props.resourceType}`;
|
: `${baseUrl}?resourceType=${props.resourceType}`;
|
||||||
});
|
});
|
||||||
function openDialog() {
|
function openDialog() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
function confirm() {
|
function confirm() {
|
||||||
emit('choose', props.multiple ? chooseResources.value : currentChoose.value, props.attrName);
|
emit(
|
||||||
|
'choose',
|
||||||
|
props.multiple ? chooseResources.value : currentChoose.value,
|
||||||
|
props.attrName,
|
||||||
|
);
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
}
|
||||||
watch(
|
watch(
|
||||||
@@ -85,7 +96,7 @@ watch(
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</EasyFlowPanelModal>
|
</EasyFlowPanelModal>
|
||||||
<ElButton @click="openDialog()">
|
<ElButton :disabled="disabled" @click="openDialog()">
|
||||||
{{ $t('button.choose') }}
|
{{ $t('button.choose') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { EventType } from '@ag-ui/client';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
|
import { EasyFlowAguiClient, EasyFlowAguiProjectionError } from './client';
|
||||||
|
import { easyFlowAguiCustomEvent } from './custom-events';
|
||||||
import { isRetryableAguiTransportError } from './reconnect';
|
import { isRetryableAguiTransportError } from './reconnect';
|
||||||
|
|
||||||
vi.mock('#/api/request', () => ({
|
vi.mock('#/api/request', () => ({
|
||||||
@@ -276,6 +277,69 @@ describe('easyFlowAguiClient', () => {
|
|||||||
expect(received.at(-1)).toBe(EventType.RUN_FINISHED);
|
expect(received.at(-1)).toBe(EventType.RUN_FINISHED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('yields a paint opportunity after knowledge retrieval starts', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let paintCallback: FrameRequestCallback | undefined;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'requestAnimationFrame',
|
||||||
|
vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
paintCallback = callback;
|
||||||
|
return 1;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () =>
|
||||||
|
sse([
|
||||||
|
{ runId: 'run-1', threadId: '101', type: EventType.RUN_STARTED },
|
||||||
|
{
|
||||||
|
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
|
||||||
|
type: EventType.CUSTOM,
|
||||||
|
value: {
|
||||||
|
status: 'running',
|
||||||
|
statusKey: 'knowledge-retrieval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
|
||||||
|
type: EventType.CUSTOM,
|
||||||
|
value: {
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'knowledge-retrieval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
runId: 'run-1',
|
||||||
|
threadId: '101',
|
||||||
|
type: EventType.RUN_FINISHED,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const receivedStatuses: string[] = [];
|
||||||
|
|
||||||
|
const runPromise = new EasyFlowAguiClient().run({
|
||||||
|
onEvent: (event) => {
|
||||||
|
if (event.type === EventType.CUSTOM) {
|
||||||
|
const value = event.value as Record<string, unknown>;
|
||||||
|
receivedStatuses.push(String(value.status));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
threadId: '101',
|
||||||
|
url: '/api/v1/agent/1/agui/run',
|
||||||
|
userMessage: { content: '几点退房', id: 'user-1', role: 'user' },
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(receivedStatuses).toEqual(['running']);
|
||||||
|
|
||||||
|
paintCallback?.(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
|
expect(receivedStatuses).toEqual(['running', 'done']);
|
||||||
|
});
|
||||||
|
|
||||||
it('replays a completed run from the server journal after refresh', async () => {
|
it('replays a completed run from the server journal after refresh', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { events } from 'fetch-event-stream';
|
|||||||
|
|
||||||
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
|
import { createEventStreamHeaders, resolveApiUrl } from '#/api/request';
|
||||||
|
|
||||||
|
import { easyFlowAguiCustomEvent } from './custom-events';
|
||||||
|
|
||||||
export interface EasyFlowAguiRunOptions {
|
export interface EasyFlowAguiRunOptions {
|
||||||
forwardedProps?: Record<string, unknown>;
|
forwardedProps?: Record<string, unknown>;
|
||||||
onCursor?: (cursor: number) => void;
|
onCursor?: (cursor: number) => void;
|
||||||
@@ -101,6 +103,31 @@ function waitForToolStartPaint(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断事件是否开启了需要即时呈现的工具执行状态。
|
||||||
|
*
|
||||||
|
* @param event AG-UI 事件
|
||||||
|
* @returns 标准工具开始或知识库检索开始时为 true
|
||||||
|
*/
|
||||||
|
function startsVisibleToolExecution(event: AguiEvent) {
|
||||||
|
if (event.type === EventType.TOOL_CALL_START) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.type !== EventType.CUSTOM ||
|
||||||
|
event.name !== easyFlowAguiCustomEvent.knowledgeRetrievalStatus
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const value =
|
||||||
|
event.value &&
|
||||||
|
typeof event.value === 'object' &&
|
||||||
|
!Array.isArray(event.value)
|
||||||
|
? (event.value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return String(value.status || '').toLowerCase() === 'running';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EasyFlow 的无头 AG-UI 运行客户端。
|
* EasyFlow 的无头 AG-UI 运行客户端。
|
||||||
*
|
*
|
||||||
@@ -174,7 +201,7 @@ export class EasyFlowAguiClient {
|
|||||||
) {
|
) {
|
||||||
terminalReceived = true;
|
terminalReceived = true;
|
||||||
}
|
}
|
||||||
if (event.type === EventType.TOOL_CALL_START) {
|
if (startsVisibleToolExecution(event as AguiEvent)) {
|
||||||
await waitForToolStartPaint();
|
await waitForToolStartPaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +260,7 @@ export class EasyFlowAguiClient {
|
|||||||
if (Number.isSafeInteger(cursor) && cursor > 0) {
|
if (Number.isSafeInteger(cursor) && cursor > 0) {
|
||||||
options.onCursor?.(cursor);
|
options.onCursor?.(cursor);
|
||||||
}
|
}
|
||||||
if (event.type === EventType.TOOL_CALL_START) {
|
if (startsVisibleToolExecution(event as AguiEvent)) {
|
||||||
await waitForToolStartPaint();
|
await waitForToolStartPaint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,76 @@ describe('aG-UI wire contract and timeline projection', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges knowledge retrieval tool and status events within one turn', () => {
|
||||||
|
const items: ChatTimelineItem[] = [];
|
||||||
|
const state = createAguiTimelineProjectionState();
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
toolCallId: 'tool-faq-1',
|
||||||
|
toolCallName: 'retrieve_knowledge_homeinn_faq',
|
||||||
|
type: EventType.TOOL_CALL_START,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
|
||||||
|
type: EventType.CUSTOM,
|
||||||
|
value: {
|
||||||
|
label: '已检索知识库',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'knowledge-retrieval',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: 'Retrieved 1 relevant document(s)',
|
||||||
|
messageId: 'tool-result-faq-1',
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: 'tool-faq-1',
|
||||||
|
type: EventType.TOOL_CALL_RESULT,
|
||||||
|
},
|
||||||
|
].map((event) => EventSchemas.parse(event));
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
applyAguiEventToTimeline(items, event, { roundId: 'round-faq' }, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
label: '已检索知识库',
|
||||||
|
roundId: 'round-faq',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'knowledge-retrieval:round-faq',
|
||||||
|
type: 'status',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects failed knowledge retrieval without exposing tool details', () => {
|
||||||
|
const items: ChatTimelineItem[] = [];
|
||||||
|
applyAguiEventToTimeline(
|
||||||
|
items,
|
||||||
|
EventSchemas.parse({
|
||||||
|
name: easyFlowAguiCustomEvent.knowledgeRetrievalStatus,
|
||||||
|
type: EventType.CUSTOM,
|
||||||
|
value: {
|
||||||
|
internalError: 'private stack',
|
||||||
|
status: 'error',
|
||||||
|
statusKey: 'knowledge-retrieval',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ roundId: 'round-failed-knowledge' },
|
||||||
|
createAguiTimelineProjectionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
label: '知识库检索失败',
|
||||||
|
status: 'error',
|
||||||
|
statusKey: 'knowledge-retrieval:round-failed-knowledge',
|
||||||
|
tone: 'danger',
|
||||||
|
type: 'status',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(items)).not.toContain('private stack');
|
||||||
|
});
|
||||||
|
|
||||||
it('projects Skill invocation status in place through the strict public fields', () => {
|
it('projects Skill invocation status in place through the strict public fields', () => {
|
||||||
const items: ChatTimelineItem[] = [];
|
const items: ChatTimelineItem[] = [];
|
||||||
const state = createAguiTimelineProjectionState();
|
const state = createAguiTimelineProjectionState();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ChatTimelineKnowledgeHit,
|
ChatTimelineKnowledgeHit,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineSkillInvocationStatus,
|
ChatTimelineSkillInvocationStatus,
|
||||||
|
ChatTimelineStatusStatus,
|
||||||
ChatTimelineToolStatus,
|
ChatTimelineToolStatus,
|
||||||
} from '@easyflow/common-ui';
|
} from '@easyflow/common-ui';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import { easyFlowAguiCustomEvent } from './custom-events';
|
|||||||
export interface AguiTimelineProjectionOptions {
|
export interface AguiTimelineProjectionOptions {
|
||||||
finishedAt?: number;
|
finishedAt?: number;
|
||||||
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
|
onInputAccepted?: (payload: Record<string, unknown>) => Promise<void> | void;
|
||||||
|
runErrorMessage?: string;
|
||||||
roundId?: string;
|
roundId?: string;
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
}
|
}
|
||||||
@@ -130,6 +132,13 @@ function asyncToolStatus(
|
|||||||
return 'running';
|
return 'running';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function knowledgeRetrievalStatus(value: unknown): ChatTimelineStatusStatus {
|
||||||
|
const status = asText(value).trim().toLowerCase();
|
||||||
|
if (status === 'running') return 'running';
|
||||||
|
if (status === 'error' || status === 'failed') return 'error';
|
||||||
|
return 'done';
|
||||||
|
}
|
||||||
|
|
||||||
function statusKey(
|
function statusKey(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
options: AguiTimelineProjectionOptions,
|
options: AguiTimelineProjectionOptions,
|
||||||
@@ -274,7 +283,7 @@ function applyCustomEvent(
|
|||||||
if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) {
|
if (event.name === easyFlowAguiCustomEvent.knowledgeRetrievalStatus) {
|
||||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||||
items,
|
items,
|
||||||
asText(payload.status).toLowerCase() === 'running' ? 'running' : 'done',
|
knowledgeRetrievalStatus(payload.status),
|
||||||
statusKey(payload, options, 'knowledge-retrieval'),
|
statusKey(payload, options, 'knowledge-retrieval'),
|
||||||
turnMetadata,
|
turnMetadata,
|
||||||
);
|
);
|
||||||
@@ -361,7 +370,7 @@ export function applyAguiEventToTimeline(
|
|||||||
}
|
}
|
||||||
ChatTimelineBuilder.appendError(
|
ChatTimelineBuilder.appendError(
|
||||||
items,
|
items,
|
||||||
event.message || '请求失败',
|
options.runErrorMessage || event.message || '请求失败',
|
||||||
metadata(options, state),
|
metadata(options, state),
|
||||||
);
|
);
|
||||||
ChatTimelineBuilder.finalize(items, {
|
ChatTimelineBuilder.finalize(items, {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { $t } from '#/locales';
|
|||||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||||
|
|
||||||
|
import { resolveWorkflowParameterDisplayName } from './workflowFormParameters';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
workflowId: any;
|
workflowId: any;
|
||||||
node: any;
|
node: any;
|
||||||
@@ -31,7 +33,7 @@ const parameterDisplayNameMap = computed(() => {
|
|||||||
return new Map(
|
return new Map(
|
||||||
singleRunParameters.value.map((parameter: any) => [
|
singleRunParameters.value.map((parameter: any) => [
|
||||||
String(parameter.name || ''),
|
String(parameter.name || ''),
|
||||||
String(parameter.displayName || parameter.formLabel || parameter.name || ''),
|
resolveWorkflowParameterDisplayName(parameter),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { ElButton, ElLink, ElMessage } from 'element-plus';
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
Delete,
|
||||||
|
Document,
|
||||||
|
UploadFilled,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import { ElButton, ElIcon, ElLink, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendWorkflowFileValues,
|
appendWorkflowFileValues,
|
||||||
buildWorkflowFileValueFromResource,
|
|
||||||
buildWorkflowFileValueFromUpload,
|
buildWorkflowFileValueFromUpload,
|
||||||
formatWorkflowFileSize,
|
formatWorkflowFileSize,
|
||||||
normalizeWorkflowFileValues,
|
normalizeWorkflowFileValues,
|
||||||
@@ -19,6 +23,10 @@ import {
|
|||||||
} from './workflowFileValue';
|
} from './workflowFileValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [Array, Object],
|
type: [Array, Object],
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -28,21 +36,25 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
|
||||||
const uploadLoading = ref(false);
|
const uploadLoading = ref(false);
|
||||||
|
const dragActive = ref(false);
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const currentFiles = computed(() => normalizeWorkflowFileValues(props.modelValue));
|
const currentFiles = computed(() =>
|
||||||
|
normalizeWorkflowFileValues(props.modelValue),
|
||||||
|
);
|
||||||
|
const maxSingleFileSizeText = formatWorkflowFileSize(
|
||||||
|
WORKFLOW_FILE_LIMITS.maxSingleSize,
|
||||||
|
).replace('.0 ', ' ');
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (uploadLoading.value) {
|
if (props.disabled || uploadLoading.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNativeFileChange(event: Event) {
|
async function uploadFiles(files: File[]) {
|
||||||
const input = event.target as HTMLInputElement;
|
if (props.disabled || files.length === 0) {
|
||||||
const files = Array.from(input.files || []);
|
|
||||||
if (files.length === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,9 +64,17 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
const uploadedFiles = [];
|
const uploadedFiles = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
uploadedFiles.push(buildWorkflowFileValueFromUpload(file, res?.data?.path));
|
uploadedFiles.push(
|
||||||
|
buildWorkflowFileValueFromUpload(file, res?.data?.path),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, uploadedFiles);
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextFiles = appendWorkflowFileValues(
|
||||||
|
currentFiles.value,
|
||||||
|
uploadedFiles,
|
||||||
|
);
|
||||||
validateWorkflowFileValues(nextFiles);
|
validateWorkflowFileValues(nextFiles);
|
||||||
emit('update:modelValue', nextFiles);
|
emit('update:modelValue', nextFiles);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -62,32 +82,38 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
console.error('工作流文件上传失败', error);
|
console.error('工作流文件上传失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
uploadLoading.value = false;
|
uploadLoading.value = false;
|
||||||
input.value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChooseResource(resources: any) {
|
async function handleNativeFileChange(event: Event) {
|
||||||
try {
|
const input = event.target as HTMLInputElement;
|
||||||
const resourceList = Array.isArray(resources) ? resources : [resources];
|
await uploadFiles([...(input.files || [])]);
|
||||||
const fileValues = resourceList
|
input.value = '';
|
||||||
.map((resource) => buildWorkflowFileValueFromResource(resource || {}))
|
}
|
||||||
.filter(Boolean);
|
|
||||||
const nextFiles = appendWorkflowFileValues(currentFiles.value, fileValues);
|
function setDragActive(active: boolean) {
|
||||||
validateWorkflowFileValues(nextFiles);
|
if (!props.disabled) {
|
||||||
emit('update:modelValue', nextFiles);
|
dragActive.value = active;
|
||||||
} catch (error: any) {
|
|
||||||
ElMessage.error(error?.message || '素材文件选择失败');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
dragActive.value = false;
|
||||||
|
if (props.disabled || uploadLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await uploadFiles([...(event.dataTransfer?.files || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
function removeFile(filePath: string) {
|
function removeFile(filePath: string) {
|
||||||
const nextFiles = currentFiles.value.filter((item) => item.filePath !== filePath);
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextFiles = currentFiles.value.filter(
|
||||||
|
(item) => item.filePath !== filePath,
|
||||||
|
);
|
||||||
emit('update:modelValue', nextFiles);
|
emit('update:modelValue', nextFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFiles() {
|
|
||||||
emit('update:modelValue', []);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,14 +122,42 @@ function clearFiles() {
|
|||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
class="workflow-file-input__native"
|
class="workflow-file-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
|
:disabled="disabled"
|
||||||
multiple
|
multiple
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="workflow-file-input__hint">
|
<div
|
||||||
最多 {{ WORKFLOW_FILE_LIMITS.maxCount }} 个文件,单个不超过
|
v-if="currentFiles.length === 0"
|
||||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxSingleSize) }},总计不超过
|
class="workflow-file-input__dropzone"
|
||||||
{{ formatWorkflowFileSize(WORKFLOW_FILE_LIMITS.maxTotalSize) }}
|
:class="{ 'is-disabled': disabled, 'is-dragging': dragActive }"
|
||||||
|
@dragenter.prevent="setDragActive(true)"
|
||||||
|
@dragover.prevent="setDragActive(true)"
|
||||||
|
@dragleave.prevent="setDragActive(false)"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="workflow-file-input__upload-trigger"
|
||||||
|
type="button"
|
||||||
|
:disabled="disabled || uploadLoading"
|
||||||
|
@click="triggerSelectFile"
|
||||||
|
>
|
||||||
|
<ElIcon class="workflow-file-input__upload-icon">
|
||||||
|
<UploadFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="workflow-file-input__dropzone-copy">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
disabled
|
||||||
|
? '未上传文件'
|
||||||
|
: uploadLoading
|
||||||
|
? '正在上传…'
|
||||||
|
: '拖入文件或点击上传'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<small>单个文件不超过 {{ maxSingleFileSizeText }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
<div v-if="currentFiles.length > 0" class="workflow-file-input__list">
|
||||||
@@ -112,12 +166,21 @@ function clearFiles() {
|
|||||||
:key="item.filePath"
|
:key="item.filePath"
|
||||||
class="workflow-file-input__summary"
|
class="workflow-file-input__summary"
|
||||||
>
|
>
|
||||||
|
<ElIcon class="workflow-file-input__file-icon">
|
||||||
|
<Document />
|
||||||
|
</ElIcon>
|
||||||
<div class="workflow-file-input__content">
|
<div class="workflow-file-input__content">
|
||||||
<div class="workflow-file-input__name">
|
<div class="workflow-file-input__name">
|
||||||
{{ item.fileName }}
|
{{ item.fileName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="workflow-file-input__meta">
|
<div class="workflow-file-input__meta">
|
||||||
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
<span>{{ formatWorkflowFileSize(item.size) }}</span>
|
||||||
|
<span class="workflow-file-input__ready">
|
||||||
|
<ElIcon><CircleCheck /></ElIcon>
|
||||||
|
已上传
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ElLink
|
<ElLink
|
||||||
v-if="item.url || item.filePath"
|
v-if="item.url || item.filePath"
|
||||||
:href="item.url || item.filePath"
|
:href="item.url || item.filePath"
|
||||||
@@ -126,32 +189,15 @@ function clearFiles() {
|
|||||||
>
|
>
|
||||||
{{ $t('button.view') }}
|
{{ $t('button.view') }}
|
||||||
</ElLink>
|
</ElLink>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ElButton text type="danger" @click="removeFile(item.filePath)">
|
|
||||||
{{ $t('button.delete') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="workflow-file-input__actions">
|
|
||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
v-if="!disabled"
|
||||||
plain
|
:icon="Delete"
|
||||||
:loading="uploadLoading"
|
|
||||||
@click="triggerSelectFile"
|
|
||||||
>
|
|
||||||
{{ currentFiles.length > 0 ? '继续上传' : $t('button.upload') }}
|
|
||||||
</ElButton>
|
|
||||||
<ChooseResource attr-name="file" multiple @choose="handleChooseResource" />
|
|
||||||
<ElButton
|
|
||||||
v-if="currentFiles.length > 0"
|
|
||||||
text
|
text
|
||||||
type="danger"
|
circle
|
||||||
@click="clearFiles"
|
aria-label="删除文件"
|
||||||
>
|
@click="removeFile(item.filePath)"
|
||||||
清空
|
/>
|
||||||
</ElButton>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -161,16 +207,83 @@ function clearFiles() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__native {
|
.workflow-file-input__native {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__hint {
|
.workflow-file-input__dropzone {
|
||||||
font-size: 12px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 56px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
line-height: 1.5;
|
background: hsl(var(--surface-subtle));
|
||||||
|
border: 1px dashed var(--el-border-color);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
transition:
|
||||||
|
color var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
|
background-color var(--motion-duration-base) var(--motion-ease-standard),
|
||||||
|
border-color var(--motion-duration-base) var(--motion-ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone:hover,
|
||||||
|
.workflow-file-input__dropzone.is-dragging {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 6%);
|
||||||
|
border-color: hsl(var(--primary) / 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone.is-disabled,
|
||||||
|
.workflow-file-input__dropzone.is-disabled:hover {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-color: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__upload-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row wrap;
|
||||||
|
gap: var(--space-1) var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__dropzone-copy small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__list {
|
.workflow-file-input__list {
|
||||||
@@ -181,39 +294,46 @@ function clearFiles() {
|
|||||||
|
|
||||||
.workflow-file-input__summary {
|
.workflow-file-input__summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
padding: var(--space-3);
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--el-border-color-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--el-fill-color-blank);
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-file-input__file-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 18px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__content {
|
.workflow-file-input__content {
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__name {
|
.workflow-file-input__name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
word-break: break-word;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__meta {
|
.workflow-file-input__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-file-input__actions {
|
.workflow-file-input__ready {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-wrap: wrap;
|
gap: var(--space-1);
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
color: var(--el-color-success);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
|||||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||||
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
|
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
|
||||||
|
|
||||||
|
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
||||||
import { hasWorkflowImageValue } from './workflowImageValue';
|
import { hasWorkflowImageValue } from './workflowImageValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
type: Array<any>,
|
type: Array<any>,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -45,6 +50,10 @@ function isResource(contentType: any) {
|
|||||||
function isFileContentType(contentType: any) {
|
function isFileContentType(contentType: any) {
|
||||||
return contentType === 'file';
|
return contentType === 'file';
|
||||||
}
|
}
|
||||||
|
function isWideItem(item: any) {
|
||||||
|
const contentType = getContentType(item);
|
||||||
|
return item.formType === 'textarea' || contentType === 'image';
|
||||||
|
}
|
||||||
function getCheckboxOptions(item: any) {
|
function getCheckboxOptions(item: any) {
|
||||||
if (item.enums) {
|
if (item.enums) {
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +82,9 @@ function buildRules(item: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
callback(value.length > 0 ? undefined : new Error($t('message.required')));
|
callback(
|
||||||
|
value.length > 0 ? undefined : new Error($t('message.required')),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
@@ -90,10 +101,16 @@ function buildRules(item: any) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
function updateParam(name: string, value: any) {
|
function updateParam(name: string, value: any) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newValue = { ...props.runParams, [name]: value };
|
const newValue = { ...props.runParams, [name]: value };
|
||||||
emit('update:runParams', newValue);
|
emit('update:runParams', newValue);
|
||||||
}
|
}
|
||||||
function choose(data: any, propName: string) {
|
function choose(data: any, propName: string) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateParam(propName, data.resourceUrl);
|
updateParam(propName, data.resourceUrl);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -101,20 +118,24 @@ function choose(data: any, propName: string) {
|
|||||||
<template>
|
<template>
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-for="(item, idx) in parameters"
|
v-for="(item, idx) in parameters"
|
||||||
|
class="workflow-form-item"
|
||||||
|
:class="{ 'is-wide': isWideItem(item) }"
|
||||||
:prop="`${propPrefix}${item.name}`"
|
:prop="`${propPrefix}${item.name}`"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:label="item.formLabel || item.name"
|
:label="resolveWorkflowParameterLabel(item)"
|
||||||
:rules="buildRules(item)"
|
:rules="buildRules(item)"
|
||||||
>
|
>
|
||||||
<template v-if="getContentType(item) === 'text'">
|
<template v-if="getContentType(item) === 'text'">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-if="item.formType === 'input' || !item.formType"
|
v-if="item.formType === 'input' || !item.formType"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
/>
|
/>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-if="item.formType === 'select'"
|
v-if="item.formType === 'select'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -123,6 +144,7 @@ function choose(data: any, propName: string) {
|
|||||||
/>
|
/>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-if="item.formType === 'textarea'"
|
v-if="item.formType === 'textarea'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -131,12 +153,14 @@ function choose(data: any, propName: string) {
|
|||||||
/>
|
/>
|
||||||
<ElRadioGroup
|
<ElRadioGroup
|
||||||
v-if="item.formType === 'radio'"
|
v-if="item.formType === 'radio'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:options="getCheckboxOptions(item)"
|
:options="getCheckboxOptions(item)"
|
||||||
/>
|
/>
|
||||||
<ElCheckboxGroup
|
<ElCheckboxGroup
|
||||||
v-if="item.formType === 'checkbox'"
|
v-if="item.formType === 'checkbox'"
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:options="getCheckboxOptions(item)"
|
:options="getCheckboxOptions(item)"
|
||||||
@@ -144,6 +168,7 @@ function choose(data: any, propName: string) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="getContentType(item) === 'other'">
|
<template v-if="getContentType(item) === 'other'">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
@@ -151,23 +176,30 @@ function choose(data: any, propName: string) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-if="isFileContentType(getContentType(item))">
|
<template v-if="isFileContentType(getContentType(item))">
|
||||||
<WorkflowFileInput
|
<WorkflowFileInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="getContentType(item) === 'image'">
|
<template v-if="getContentType(item) === 'image'">
|
||||||
<WorkflowImageInput
|
<WorkflowImageInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="isResource(getContentType(item))">
|
<template v-if="isResource(getContentType(item))">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
:disabled="disabled"
|
||||||
:model-value="runParams[item.name]"
|
:model-value="runParams[item.name]"
|
||||||
@update:model-value="(val) => updateParam(item.name, val)"
|
@update:model-value="(val) => updateParam(item.name, val)"
|
||||||
:placeholder="item.formPlaceholder"
|
:placeholder="item.formPlaceholder"
|
||||||
/>
|
/>
|
||||||
<ChooseResource :attr-name="item.name" @choose="choose" />
|
<ChooseResource
|
||||||
|
:attr-name="item.name"
|
||||||
|
:disabled="disabled"
|
||||||
|
@choose="choose"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
|
<ElAlert v-if="item.formDescription" type="info" style="margin-top: 5px">
|
||||||
{{ item.formDescription }}
|
{{ item.formDescription }}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
} from './workflowImageValue';
|
} from './workflowImageValue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: [String, Object],
|
type: [String, Object],
|
||||||
default: undefined,
|
default: undefined,
|
||||||
@@ -33,9 +37,7 @@ const urlInput = ref('');
|
|||||||
const currentImage = computed(() =>
|
const currentImage = computed(() =>
|
||||||
normalizeWorkflowImageValue(props.modelValue),
|
normalizeWorkflowImageValue(props.modelValue),
|
||||||
);
|
);
|
||||||
const previewUrl = computed(() =>
|
const previewUrl = computed(() => getWorkflowImagePreviewUrl(props.modelValue));
|
||||||
getWorkflowImagePreviewUrl(props.modelValue),
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
@@ -47,6 +49,9 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function applyUrl() {
|
function applyUrl() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -55,7 +60,7 @@ function applyUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerSelectFile() {
|
function triggerSelectFile() {
|
||||||
if (!uploadLoading.value) {
|
if (!props.disabled && !uploadLoading.value) {
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,13 +68,16 @@ function triggerSelectFile() {
|
|||||||
async function handleNativeFileChange(event: Event) {
|
async function handleNativeFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file) {
|
if (props.disabled || !file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadLoading.value = true;
|
uploadLoading.value = true;
|
||||||
try {
|
try {
|
||||||
validateWorkflowImageFile(file);
|
validateWorkflowImageFile(file);
|
||||||
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
const response = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(
|
emit(
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
buildWorkflowImageValueFromUpload(file, response?.data?.path),
|
||||||
@@ -84,6 +92,9 @@ async function handleNativeFileChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleChooseResource(resource: any) {
|
function handleChooseResource(resource: any) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emit(
|
emit(
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
@@ -95,6 +106,9 @@ function handleChooseResource(resource: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearImage() {
|
function clearImage() {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
urlInput.value = '';
|
urlInput.value = '';
|
||||||
emit('update:modelValue', undefined);
|
emit('update:modelValue', undefined);
|
||||||
}
|
}
|
||||||
@@ -107,6 +121,7 @@ function clearImage() {
|
|||||||
class="workflow-image-input__native"
|
class="workflow-image-input__native"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
:accept="WORKFLOW_IMAGE_LIMITS.accept"
|
||||||
|
:disabled="disabled"
|
||||||
@change="handleNativeFileChange"
|
@change="handleNativeFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -143,11 +158,12 @@ function clearImage() {
|
|||||||
<ElInput
|
<ElInput
|
||||||
v-model="urlInput"
|
v-model="urlInput"
|
||||||
clearable
|
clearable
|
||||||
|
:disabled="disabled"
|
||||||
placeholder="输入 HTTP/HTTPS 图片 URL"
|
placeholder="输入 HTTP/HTTPS 图片 URL"
|
||||||
@keyup.enter="applyUrl"
|
@keyup.enter="applyUrl"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<ElButton @click="applyUrl">使用 URL</ElButton>
|
<ElButton :disabled="disabled" @click="applyUrl">使用 URL</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElInput>
|
</ElInput>
|
||||||
|
|
||||||
@@ -155,6 +171,7 @@ function clearImage() {
|
|||||||
<ElButton
|
<ElButton
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
|
:disabled="disabled"
|
||||||
:loading="uploadLoading"
|
:loading="uploadLoading"
|
||||||
@click="triggerSelectFile"
|
@click="triggerSelectFile"
|
||||||
>
|
>
|
||||||
@@ -162,10 +179,16 @@ function clearImage() {
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
<ChooseResource
|
<ChooseResource
|
||||||
attr-name="image"
|
attr-name="image"
|
||||||
|
:disabled="disabled"
|
||||||
:resource-type="0"
|
:resource-type="0"
|
||||||
@choose="handleChooseResource"
|
@choose="handleChooseResource"
|
||||||
/>
|
/>
|
||||||
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
|
<ElButton
|
||||||
|
v-if="currentImage && !disabled"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
@click="clearImage"
|
||||||
|
>
|
||||||
清空
|
清空
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { defineComponent, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import WorkflowFileInput from '../WorkflowFileInput.vue';
|
||||||
|
|
||||||
|
describe('workflow file input', () => {
|
||||||
|
it('shows the upload area again after the uploaded file is deleted', async () => {
|
||||||
|
const Host = defineComponent({
|
||||||
|
components: { WorkflowFileInput },
|
||||||
|
setup() {
|
||||||
|
const value = ref([
|
||||||
|
{
|
||||||
|
fileName: '需求说明.pdf',
|
||||||
|
filePath: '/files/requirements.pdf',
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return { value };
|
||||||
|
},
|
||||||
|
template: '<WorkflowFileInput v-model="value" />',
|
||||||
|
});
|
||||||
|
const wrapper = mount(Host);
|
||||||
|
|
||||||
|
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.get('button[aria-label="删除文件"]').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__dropzone').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the delete action and disables upload when parameters are locked', async () => {
|
||||||
|
const wrapper = mount(WorkflowFileInput, {
|
||||||
|
props: {
|
||||||
|
disabled: true,
|
||||||
|
modelValue: [
|
||||||
|
{
|
||||||
|
fileName: '需求说明.pdf',
|
||||||
|
filePath: '/files/requirements.pdf',
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('button[aria-label="删除文件"]').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.workflow-file-input__summary').exists()).toBe(true);
|
||||||
|
|
||||||
|
await wrapper.setProps({ modelValue: [] });
|
||||||
|
expect(
|
||||||
|
wrapper.get('.workflow-file-input__upload-trigger').attributes(),
|
||||||
|
).toHaveProperty('disabled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { resolveWorkflowFormParameters } from '../workflowFormParameters';
|
import {
|
||||||
|
resolveWorkflowFormParameters,
|
||||||
|
resolveWorkflowParameterDisplayName,
|
||||||
|
resolveWorkflowParameterLabel,
|
||||||
|
} from '../workflowFormParameters';
|
||||||
|
|
||||||
describe('resolveWorkflowFormParameters', () => {
|
describe('resolveWorkflowFormParameters', () => {
|
||||||
it('uses the image parameter when a legacy schema still declares text', () => {
|
it('uses the image parameter when a legacy schema still declares text', () => {
|
||||||
@@ -73,4 +77,46 @@ describe('resolveWorkflowFormParameters', () => {
|
|||||||
formLabel: '背景资料',
|
formLabel: '背景资料',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the configured parameter name for a default trial-run label', () => {
|
||||||
|
expect(
|
||||||
|
resolveWorkflowParameterLabel({
|
||||||
|
name: 'customer_name',
|
||||||
|
formLabel: '新字段',
|
||||||
|
}),
|
||||||
|
).toBe('customer_name');
|
||||||
|
expect(
|
||||||
|
resolveWorkflowParameterDisplayName({
|
||||||
|
name: 'start_1.customer_name',
|
||||||
|
displayName: '开始节点 > 新字段',
|
||||||
|
}),
|
||||||
|
).toBe('开始节点 > customer_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured parameter name instead of a type-derived label', () => {
|
||||||
|
const parameter = {
|
||||||
|
name: 'start_1.file111',
|
||||||
|
formLabel: '文件',
|
||||||
|
displayName: '开始节点 > 文件',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveWorkflowParameterLabel(parameter)).toBe('file111');
|
||||||
|
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
|
||||||
|
'开始节点 > file111',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the configured system question label', () => {
|
||||||
|
const parameter = {
|
||||||
|
name: 'user_input',
|
||||||
|
formLabel: '用户问题123',
|
||||||
|
displayName: '流程开始 > 用户问题123',
|
||||||
|
systemReserved: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resolveWorkflowParameterLabel(parameter)).toBe('用户问题123');
|
||||||
|
expect(resolveWorkflowParameterDisplayName(parameter)).toBe(
|
||||||
|
'流程开始 > 用户问题123',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildWorkflowFormParameterSummaries,
|
||||||
buildWorkflowFormSubmissionImages,
|
buildWorkflowFormSubmissionImages,
|
||||||
buildWorkflowFormSubmissionText,
|
buildWorkflowFormSubmissionText,
|
||||||
hasRequiredWorkflowFormParameters,
|
hasRequiredWorkflowFormParameters,
|
||||||
@@ -67,4 +68,43 @@ describe('workflowFormPresentation', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds compact summaries for configured workflow parameters', () => {
|
||||||
|
expect(
|
||||||
|
buildWorkflowFormParameterSummaries(
|
||||||
|
[
|
||||||
|
{ name: 'customer', formLabel: '客户名称', required: true },
|
||||||
|
{ name: 'scene', formLabel: '业务场景', required: false },
|
||||||
|
{ name: 'files', formLabel: '需求附件', required: true },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
customer: '华北分公司',
|
||||||
|
scene: '',
|
||||||
|
files: [{ fileName: '需求说明.pdf' }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
key: 'customer',
|
||||||
|
label: 'customer',
|
||||||
|
ready: true,
|
||||||
|
required: true,
|
||||||
|
value: '华北分公司',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'scene',
|
||||||
|
label: 'scene',
|
||||||
|
ready: false,
|
||||||
|
required: false,
|
||||||
|
value: '待填写',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'files',
|
||||||
|
label: 'files',
|
||||||
|
ready: true,
|
||||||
|
required: true,
|
||||||
|
value: '需求说明.pdf',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkflowRunDraftKey,
|
||||||
|
hasWorkflowRunDraftContent,
|
||||||
|
readWorkflowRunDraft,
|
||||||
|
removeWorkflowRunDraft,
|
||||||
|
WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
writeWorkflowRunDraft,
|
||||||
|
} from '../workflowRunDraft';
|
||||||
|
|
||||||
|
const parameters = [
|
||||||
|
{ contentType: 'text', formType: 'input', name: 'company' },
|
||||||
|
{ contentType: 'file', formType: 'input', name: 'attachment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('workflowRunDraft', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates drafts by workflow, account and run mode', () => {
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-1', 'tenant:user-2', false),
|
||||||
|
);
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-2', 'tenant:user-1', false),
|
||||||
|
);
|
||||||
|
expect(buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', true)).not.toBe(
|
||||||
|
buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores current compatible fields within twelve hours', () => {
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
question: '分析合同',
|
||||||
|
values: {
|
||||||
|
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
|
||||||
|
company: '华北分公司',
|
||||||
|
removedField: '旧字段',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readWorkflowRunDraft(sessionStorage, key, parameters, 2000)).toEqual(
|
||||||
|
{
|
||||||
|
question: '分析合同',
|
||||||
|
values: {
|
||||||
|
attachment: [{ name: 'contract.pdf', url: '/contract.pdf' }],
|
||||||
|
company: '华北分公司',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops expired drafts', () => {
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
question: '过期内容',
|
||||||
|
values: { attachment: '错误文件值', company: ['错误文本值'] },
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
readWorkflowRunDraft(
|
||||||
|
sessionStorage,
|
||||||
|
key,
|
||||||
|
parameters,
|
||||||
|
1000 + WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only persists user changes and supports an explicit reset', () => {
|
||||||
|
const defaults = { attachment: [], company: '' };
|
||||||
|
expect(hasWorkflowRunDraftContent('', defaults, defaults)).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasWorkflowRunDraftContent(
|
||||||
|
'',
|
||||||
|
{ ...defaults, company: '华北' },
|
||||||
|
defaults,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(hasWorkflowRunDraftContent('待处理', defaults, defaults)).toBe(true);
|
||||||
|
|
||||||
|
const key = buildWorkflowRunDraftKey('flow-1', 'tenant:user-1', false);
|
||||||
|
writeWorkflowRunDraft(sessionStorage, key, {
|
||||||
|
question: '待处理',
|
||||||
|
values: defaults,
|
||||||
|
});
|
||||||
|
removeWorkflowRunDraft(sessionStorage, key);
|
||||||
|
expect(sessionStorage.getItem(key)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,6 +17,85 @@ const DEFAULT_FIELD_LABELS = new Set([
|
|||||||
const GENERATED_FIELD_KEY_PATTERN =
|
const GENERATED_FIELD_KEY_PATTERN =
|
||||||
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
|
/^(?:field_[A-Za-z0-9]+|(?:text|textarea|radio|checkbox|select|file)_field(?:_\d+)?)$/;
|
||||||
|
|
||||||
|
function configuredParameterName(name: unknown) {
|
||||||
|
const normalizedName = String(name || '').trim();
|
||||||
|
const nameParts = normalizedName.split('.').filter(Boolean);
|
||||||
|
return nameParts[nameParts.length - 1] || normalizedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSystemParameter(parameter: any, name: string) {
|
||||||
|
return parameter?.systemReserved === true || name === 'user_input';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withConfiguredParameterName(label: unknown, name: unknown) {
|
||||||
|
const normalizedLabel = String(label || '').trim();
|
||||||
|
const parameterName = configuredParameterName(name);
|
||||||
|
if (!parameterName) {
|
||||||
|
return normalizedLabel;
|
||||||
|
}
|
||||||
|
const parts = normalizedLabel.split('>').map((part) => part.trim());
|
||||||
|
return parts.length > 1
|
||||||
|
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
|
||||||
|
: parameterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceDefaultParameterLabel(label: unknown, name: unknown) {
|
||||||
|
const normalizedLabel = String(label || '').trim();
|
||||||
|
const normalizedName = String(name || '').trim();
|
||||||
|
if (!normalizedLabel || !normalizedName) {
|
||||||
|
return normalizedLabel;
|
||||||
|
}
|
||||||
|
const parts = normalizedLabel.split('>').map((part) => part.trim());
|
||||||
|
const lastPart = parts[parts.length - 1] || '';
|
||||||
|
if (!DEFAULT_FIELD_LABELS.has(lastPart)) {
|
||||||
|
return normalizedLabel;
|
||||||
|
}
|
||||||
|
const parameterName = configuredParameterName(normalizedName);
|
||||||
|
return parts.length > 1
|
||||||
|
? `${parts.slice(0, -1).join(' > ')} > ${parameterName}`
|
||||||
|
: parameterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析工作流参数在表单中的展示名称。
|
||||||
|
*
|
||||||
|
* @param parameter 工作流运行参数
|
||||||
|
* @returns 用户可见的参数名称
|
||||||
|
*/
|
||||||
|
export function resolveWorkflowParameterLabel(parameter: any) {
|
||||||
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (!isSystemParameter(parameter, name) && name) {
|
||||||
|
return configuredParameterName(name);
|
||||||
|
}
|
||||||
|
const formLabel = replaceDefaultParameterLabel(parameter?.formLabel, name);
|
||||||
|
const displayName = replaceDefaultParameterLabel(
|
||||||
|
parameter?.displayName,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
return formLabel || displayName || name || '参数';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析工作流参数在引用内容中的展示名称。
|
||||||
|
*
|
||||||
|
* @param parameter 工作流运行参数
|
||||||
|
* @returns 用户可见的引用参数名称
|
||||||
|
*/
|
||||||
|
export function resolveWorkflowParameterDisplayName(parameter: any) {
|
||||||
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (!isSystemParameter(parameter, name) && name) {
|
||||||
|
return withConfiguredParameterName(
|
||||||
|
parameter?.displayName || parameter?.formLabel,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const displayName = replaceDefaultParameterLabel(
|
||||||
|
parameter?.displayName,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
return displayName || resolveWorkflowParameterLabel(parameter);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFieldLabel(field: any) {
|
function resolveFieldLabel(field: any) {
|
||||||
const key = String(field?.key || '').trim();
|
const key = String(field?.key || '').trim();
|
||||||
const label = String(field?.label || '').trim();
|
const label = String(field?.label || '').trim();
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { resolveWorkflowParameterLabel } from './workflowFormParameters';
|
||||||
import {
|
import {
|
||||||
getWorkflowImagePreviewUrl,
|
getWorkflowImagePreviewUrl,
|
||||||
normalizeWorkflowImageValue,
|
normalizeWorkflowImageValue,
|
||||||
} from './workflowImageValue';
|
} from './workflowImageValue';
|
||||||
|
|
||||||
|
export interface WorkflowFormParameterSummary {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
ready: boolean;
|
||||||
|
required: boolean;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断附加表单是否存在必填参数。
|
* 判断附加表单是否存在必填参数。
|
||||||
*
|
*
|
||||||
@@ -44,6 +53,29 @@ export function buildWorkflowFormSubmissionText(
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建运行参数的紧凑摘要。
|
||||||
|
*
|
||||||
|
* @param parameters 运行参数
|
||||||
|
* @param values 表单值
|
||||||
|
* @returns 可用于收起态展示的参数摘要
|
||||||
|
*/
|
||||||
|
export function buildWorkflowFormParameterSummaries(
|
||||||
|
parameters: any[],
|
||||||
|
values: Record<string, any>,
|
||||||
|
): WorkflowFormParameterSummary[] {
|
||||||
|
return parameters.map((parameter) => {
|
||||||
|
const value = formatWorkflowFormValue(values[parameter?.name]);
|
||||||
|
return {
|
||||||
|
key: String(parameter?.name || ''),
|
||||||
|
label: resolveWorkflowParameterLabel(parameter),
|
||||||
|
ready: Boolean(value),
|
||||||
|
required: parameter?.required === true,
|
||||||
|
value: value || '待填写',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将图片表单字段转换为聊天图片附件。
|
* 将图片表单字段转换为聊天图片附件。
|
||||||
*
|
*
|
||||||
@@ -86,7 +118,7 @@ export function buildWorkflowFormSubmissionImages(
|
|||||||
* @param value 表单字段值
|
* @param value 表单字段值
|
||||||
* @returns 用户可读文本;空值返回空字符串
|
* @returns 用户可读文本;空值返回空字符串
|
||||||
*/
|
*/
|
||||||
function formatWorkflowFormValue(value: any): string {
|
export function formatWorkflowFormValue(value: any): string {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const WORKFLOW_RUN_DRAFT_PREFIX = 'easyflow:workflow-run-draft';
|
||||||
|
const WORKFLOW_RUN_DRAFT_VERSION = 1;
|
||||||
|
|
||||||
|
export const WORKFLOW_RUN_DRAFT_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface WorkflowRunDraftPayload {
|
||||||
|
question: string;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredWorkflowRunDraft extends WorkflowRunDraftPayload {
|
||||||
|
expiresAt: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftStorage = Pick<Storage, 'getItem' | 'removeItem' | 'setItem'>;
|
||||||
|
|
||||||
|
/** 获取可用的会话存储。 */
|
||||||
|
export function getWorkflowRunDraftStorage() {
|
||||||
|
try {
|
||||||
|
return globalThis.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成按工作流、账号和运行模式隔离的草稿键。 */
|
||||||
|
export function buildWorkflowRunDraftKey(
|
||||||
|
workflowId: string,
|
||||||
|
identity: string,
|
||||||
|
shareMode: boolean,
|
||||||
|
) {
|
||||||
|
const mode = shareMode ? 'share' : 'private';
|
||||||
|
const scope = shareMode ? 'public' : identity || 'anonymous';
|
||||||
|
return [
|
||||||
|
WORKFLOW_RUN_DRAFT_PREFIX,
|
||||||
|
`v${WORKFLOW_RUN_DRAFT_VERSION}`,
|
||||||
|
mode,
|
||||||
|
encodeURIComponent(scope),
|
||||||
|
encodeURIComponent(workflowId),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取并按当前工作流参数定义过滤草稿。 */
|
||||||
|
export function readWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
parameters: any[],
|
||||||
|
now = Date.now(),
|
||||||
|
): undefined | WorkflowRunDraftPayload {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = storage.getItem(key);
|
||||||
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const draft = JSON.parse(raw) as Partial<StoredWorkflowRunDraft>;
|
||||||
|
if (
|
||||||
|
draft.version !== WORKFLOW_RUN_DRAFT_VERSION ||
|
||||||
|
typeof draft.expiresAt !== 'number' ||
|
||||||
|
draft.expiresAt <= now ||
|
||||||
|
typeof draft.question !== 'string' ||
|
||||||
|
!draft.values ||
|
||||||
|
typeof draft.values !== 'object' ||
|
||||||
|
Array.isArray(draft.values)
|
||||||
|
) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const parameter of parameters) {
|
||||||
|
const name = String(parameter?.name || '').trim();
|
||||||
|
if (
|
||||||
|
name &&
|
||||||
|
Object.prototype.hasOwnProperty.call(draft.values, name) &&
|
||||||
|
isCompatibleDraftValue(parameter, draft.values[name])
|
||||||
|
) {
|
||||||
|
values[name] = draft.values[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { question: draft.question, values };
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时无需影响页面加载。
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存工作流运行草稿,并设置 12 小时过期时间。 */
|
||||||
|
export function writeWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
draft: WorkflowRunDraftPayload,
|
||||||
|
now = Date.now(),
|
||||||
|
) {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
...draft,
|
||||||
|
expiresAt: now + WORKFLOW_RUN_DRAFT_TTL_MS,
|
||||||
|
version: WORKFLOW_RUN_DRAFT_VERSION,
|
||||||
|
} satisfies StoredWorkflowRunDraft),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用或空间不足时不阻断工作流输入。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除工作流运行草稿。 */
|
||||||
|
export function removeWorkflowRunDraft(
|
||||||
|
storage: DraftStorage | undefined,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (!storage || !key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用时无需影响重置流程。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断当前输入是否包含需要持久化的用户修改。 */
|
||||||
|
export function hasWorkflowRunDraftContent(
|
||||||
|
question: string,
|
||||||
|
values: Record<string, unknown>,
|
||||||
|
defaults: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
if (question.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.keys(values).some(
|
||||||
|
(name) => !isSameDraftValue(values[name], defaults[name]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompatibleDraftValue(parameter: any, value: unknown) {
|
||||||
|
const contentType = String(parameter?.contentType || '').toLowerCase();
|
||||||
|
const formType = String(parameter?.formType || '').toLowerCase();
|
||||||
|
if (contentType === 'file' || formType === 'checkbox') {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
if (contentType === 'image') {
|
||||||
|
return Boolean(value) && typeof value === 'object';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameDraftValue(left: unknown, right: unknown) {
|
||||||
|
if (left === right) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
actionDisabled?: boolean;
|
||||||
|
actionLabel?: string;
|
||||||
message: string;
|
message: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
action: [];
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-error-notice" role="alert">
|
<div class="chat-error-notice" role="alert">
|
||||||
<span class="chat-error-notice__icon" aria-hidden="true">!</span>
|
<span class="chat-error-notice__icon" aria-hidden="true">!</span>
|
||||||
<span>{{ message }}</span>
|
<span class="chat-error-notice__content">
|
||||||
|
<span>{{ message }}<template v-if="actionLabel">,</template></span>
|
||||||
|
<button
|
||||||
|
v-if="actionLabel"
|
||||||
|
type="button"
|
||||||
|
class="chat-error-notice__action"
|
||||||
|
:disabled="actionDisabled"
|
||||||
|
@click="emit('action')"
|
||||||
|
>
|
||||||
|
{{ actionLabel }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -39,4 +56,41 @@ defineProps<{
|
|||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__action {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: var(--space-1);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: inherit;
|
||||||
|
color: currentcolor;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--el-border-radius-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__action:hover:not(:disabled) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__action:active:not(:disabled) {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__action:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary-light-3);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-error-notice__action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ChatArtifactLoader,
|
ChatArtifactLoader,
|
||||||
ChatDocumentLoader,
|
ChatDocumentLoader,
|
||||||
ChatImageLoader,
|
ChatImageLoader,
|
||||||
|
ChatTimelineErrorItem,
|
||||||
ChatTimelineItem as ChatTimelineItemType,
|
ChatTimelineItem as ChatTimelineItemType,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
@@ -23,6 +24,8 @@ const props = defineProps<{
|
|||||||
documentLoader?: ChatDocumentLoader;
|
documentLoader?: ChatDocumentLoader;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
emptyTitle?: string;
|
emptyTitle?: string;
|
||||||
|
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
|
||||||
|
errorActionDisabled?: boolean;
|
||||||
imageLoader?: ChatImageLoader;
|
imageLoader?: ChatImageLoader;
|
||||||
items: ChatTimelineItemType[];
|
items: ChatTimelineItemType[];
|
||||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||||
@@ -33,6 +36,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||||
copyMessage: [item: ChatTimelineMessageItem];
|
copyMessage: [item: ChatTimelineMessageItem];
|
||||||
|
errorAction: [item: ChatTimelineErrorItem];
|
||||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||||
reject: [payload: ChatTimelineToolApprovalPayload];
|
reject: [payload: ChatTimelineToolApprovalPayload];
|
||||||
selectNextVariant: [item: ChatTimelineMessageItem];
|
selectNextVariant: [item: ChatTimelineMessageItem];
|
||||||
@@ -210,6 +214,10 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
|
|||||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorActionLabel(item: ChatTimelineItemType) {
|
||||||
|
return item.type === 'error' ? props.errorAction?.(item) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function isAssistantActionAnchor(item: ChatTimelineItemType) {
|
function isAssistantActionAnchor(item: ChatTimelineItemType) {
|
||||||
return (
|
return (
|
||||||
item.type === 'message' &&
|
item.type === 'message' &&
|
||||||
@@ -275,6 +283,8 @@ watch(
|
|||||||
:copy-action="copyAction"
|
:copy-action="copyAction"
|
||||||
:copyable="copyable"
|
:copyable="copyable"
|
||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
|
:error-action="errorAction"
|
||||||
|
:error-action-disabled="errorActionDisabled"
|
||||||
:image-loader="imageLoader"
|
:image-loader="imageLoader"
|
||||||
:items="entry.items"
|
:items="entry.items"
|
||||||
:regenerable="regenerable"
|
:regenerable="regenerable"
|
||||||
@@ -283,6 +293,7 @@ watch(
|
|||||||
:variant-loading="variantLoading"
|
:variant-loading="variantLoading"
|
||||||
@approve="emit('approve', $event)"
|
@approve="emit('approve', $event)"
|
||||||
@copy-message="emit('copyMessage', $event)"
|
@copy-message="emit('copyMessage', $event)"
|
||||||
|
@error-action="emit('errorAction', $event)"
|
||||||
@layout-changed="handleLayoutChanged"
|
@layout-changed="handleLayoutChanged"
|
||||||
@layout-toggle="handleLayoutToggle"
|
@layout-toggle="handleLayoutToggle"
|
||||||
@regenerate-message="emit('regenerateMessage', $event)"
|
@regenerate-message="emit('regenerateMessage', $event)"
|
||||||
@@ -306,6 +317,8 @@ watch(
|
|||||||
:assistant-avatar="assistantAvatar"
|
:assistant-avatar="assistantAvatar"
|
||||||
:item="entry.item"
|
:item="entry.item"
|
||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
|
:error-action-disabled="errorActionDisabled"
|
||||||
|
:error-action-label="errorActionLabel(entry.item)"
|
||||||
:image-loader="imageLoader"
|
:image-loader="imageLoader"
|
||||||
:approval-loading="approvalLoading"
|
:approval-loading="approvalLoading"
|
||||||
:copy-action="copyAction"
|
:copy-action="copyAction"
|
||||||
@@ -315,6 +328,7 @@ watch(
|
|||||||
:variant-loading="isVariantLoading(entry.item)"
|
:variant-loading="isVariantLoading(entry.item)"
|
||||||
@approve="emit('approve', $event)"
|
@approve="emit('approve', $event)"
|
||||||
@copy-message="emit('copyMessage', $event)"
|
@copy-message="emit('copyMessage', $event)"
|
||||||
|
@error-action="emit('errorAction', $event)"
|
||||||
@regenerate-message="emit('regenerateMessage', $event)"
|
@regenerate-message="emit('regenerateMessage', $event)"
|
||||||
@reject="emit('reject', $event)"
|
@reject="emit('reject', $event)"
|
||||||
@select-next-variant="emit('selectNextVariant', $event)"
|
@select-next-variant="emit('selectNextVariant', $event)"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ChatArtifactLoader,
|
ChatArtifactLoader,
|
||||||
ChatDocumentLoader,
|
ChatDocumentLoader,
|
||||||
ChatImageLoader,
|
ChatImageLoader,
|
||||||
|
ChatTimelineErrorItem,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineMessagePart,
|
ChatTimelineMessagePart,
|
||||||
@@ -31,6 +32,8 @@ const props = defineProps<{
|
|||||||
copyable?: boolean;
|
copyable?: boolean;
|
||||||
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
||||||
documentLoader?: ChatDocumentLoader;
|
documentLoader?: ChatDocumentLoader;
|
||||||
|
errorActionDisabled?: boolean;
|
||||||
|
errorActionLabel?: string;
|
||||||
imageLoader?: ChatImageLoader;
|
imageLoader?: ChatImageLoader;
|
||||||
item: ChatTimelineItem;
|
item: ChatTimelineItem;
|
||||||
regenerable?: boolean;
|
regenerable?: boolean;
|
||||||
@@ -41,6 +44,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||||
copyMessage: [item: ChatTimelineMessageItem];
|
copyMessage: [item: ChatTimelineMessageItem];
|
||||||
|
errorAction: [item: ChatTimelineErrorItem];
|
||||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||||
reject: [payload: ChatTimelineToolApprovalPayload];
|
reject: [payload: ChatTimelineToolApprovalPayload];
|
||||||
selectNextVariant: [item: ChatTimelineMessageItem];
|
selectNextVariant: [item: ChatTimelineMessageItem];
|
||||||
@@ -280,7 +284,10 @@ function handleCopyAction() {
|
|||||||
</div>
|
</div>
|
||||||
<ChatErrorNotice
|
<ChatErrorNotice
|
||||||
v-else-if="item.type === 'error'"
|
v-else-if="item.type === 'error'"
|
||||||
|
:action-disabled="errorActionDisabled"
|
||||||
|
:action-label="errorActionLabel"
|
||||||
:message="item.message"
|
:message="item.message"
|
||||||
|
@action="emit('errorAction', item)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ChatArtifactLoader,
|
ChatArtifactLoader,
|
||||||
ChatDocumentLoader,
|
ChatDocumentLoader,
|
||||||
ChatImageLoader,
|
ChatImageLoader,
|
||||||
|
ChatTimelineErrorItem,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
@@ -21,6 +22,8 @@ const props = defineProps<{
|
|||||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||||
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
copyAction?: (item: ChatTimelineMessageItem) => boolean | Promise<boolean>;
|
||||||
documentLoader?: ChatDocumentLoader;
|
documentLoader?: ChatDocumentLoader;
|
||||||
|
errorAction?: (item: ChatTimelineErrorItem) => string | undefined;
|
||||||
|
errorActionDisabled?: boolean;
|
||||||
imageLoader?: ChatImageLoader;
|
imageLoader?: ChatImageLoader;
|
||||||
items: ChatTimelineItem[];
|
items: ChatTimelineItem[];
|
||||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||||
@@ -32,6 +35,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
approve: [payload: ChatTimelineToolApprovalPayload];
|
approve: [payload: ChatTimelineToolApprovalPayload];
|
||||||
copyMessage: [item: ChatTimelineMessageItem];
|
copyMessage: [item: ChatTimelineMessageItem];
|
||||||
|
errorAction: [item: ChatTimelineErrorItem];
|
||||||
layoutChanged: [];
|
layoutChanged: [];
|
||||||
layoutToggle: [roundId: string];
|
layoutToggle: [roundId: string];
|
||||||
regenerateMessage: [item: ChatTimelineMessageItem];
|
regenerateMessage: [item: ChatTimelineMessageItem];
|
||||||
@@ -234,6 +238,10 @@ function canRegenerateMessage(item: ChatTimelineItem) {
|
|||||||
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
return item.type === 'message' && (props.regenerable?.(item) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function errorActionLabel(item: ChatTimelineItem) {
|
||||||
|
return item.type === 'error' ? props.errorAction?.(item) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function isVariantLoading(item: ChatTimelineItem) {
|
function isVariantLoading(item: ChatTimelineItem) {
|
||||||
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
|
return item.type === 'message' && (props.variantLoading?.(item) ?? false);
|
||||||
}
|
}
|
||||||
@@ -313,6 +321,8 @@ function handleNestedLayoutToggle() {
|
|||||||
"
|
"
|
||||||
:item="item"
|
:item="item"
|
||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
|
:error-action-disabled="errorActionDisabled"
|
||||||
|
:error-action-label="errorActionLabel(item)"
|
||||||
:image-loader="imageLoader"
|
:image-loader="imageLoader"
|
||||||
:approval-loading="approvalLoading"
|
:approval-loading="approvalLoading"
|
||||||
:copy-action="copyAction"
|
:copy-action="copyAction"
|
||||||
@@ -322,6 +332,7 @@ function handleNestedLayoutToggle() {
|
|||||||
:variant-loading="isVariantLoading(item)"
|
:variant-loading="isVariantLoading(item)"
|
||||||
@approve="emit('approve', $event)"
|
@approve="emit('approve', $event)"
|
||||||
@copy-message="emit('copyMessage', $event)"
|
@copy-message="emit('copyMessage', $event)"
|
||||||
|
@error-action="emit('errorAction', $event)"
|
||||||
@regenerate-message="emit('regenerateMessage', $event)"
|
@regenerate-message="emit('regenerateMessage', $event)"
|
||||||
@reject="emit('reject', $event)"
|
@reject="emit('reject', $event)"
|
||||||
@select-next-variant="emit('selectNextVariant', $event)"
|
@select-next-variant="emit('selectNextVariant', $event)"
|
||||||
@@ -338,6 +349,8 @@ function handleNestedLayoutToggle() {
|
|||||||
:artifact-loader="artifactLoader"
|
:artifact-loader="artifactLoader"
|
||||||
:item="finalMessage"
|
:item="finalMessage"
|
||||||
:document-loader="documentLoader"
|
:document-loader="documentLoader"
|
||||||
|
:error-action-disabled="errorActionDisabled"
|
||||||
|
:error-action-label="errorActionLabel(finalMessage)"
|
||||||
:image-loader="imageLoader"
|
:image-loader="imageLoader"
|
||||||
:copy-action="copyAction"
|
:copy-action="copyAction"
|
||||||
:copyable="canCopyMessage(finalMessage)"
|
:copyable="canCopyMessage(finalMessage)"
|
||||||
@@ -345,6 +358,7 @@ function handleNestedLayoutToggle() {
|
|||||||
:regenerate-disabled="regenerateDisabled"
|
:regenerate-disabled="regenerateDisabled"
|
||||||
:variant-loading="isVariantLoading(finalMessage)"
|
:variant-loading="isVariantLoading(finalMessage)"
|
||||||
@copy-message="emit('copyMessage', $event)"
|
@copy-message="emit('copyMessage', $event)"
|
||||||
|
@error-action="emit('errorAction', $event)"
|
||||||
@regenerate-message="emit('regenerateMessage', $event)"
|
@regenerate-message="emit('regenerateMessage', $event)"
|
||||||
@select-next-variant="emit('selectNextVariant', $event)"
|
@select-next-variant="emit('selectNextVariant', $event)"
|
||||||
@select-previous-variant="emit('selectPreviousVariant', $event)"
|
@select-previous-variant="emit('selectPreviousVariant', $event)"
|
||||||
|
|||||||
@@ -154,6 +154,28 @@ describe('chat timeline toolbar', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders and emits an accessible error action', async () => {
|
||||||
|
const errorItem: Extract<ChatTimelineItem, { type: 'error' }> = {
|
||||||
|
id: 'error-1',
|
||||||
|
message: '模型连接异常',
|
||||||
|
roundId: 'round-1',
|
||||||
|
type: 'error',
|
||||||
|
};
|
||||||
|
const wrapper = mount(ChatTimeline, {
|
||||||
|
props: {
|
||||||
|
errorAction: () => '请重试',
|
||||||
|
items: [errorItem],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryButton = wrapper.get('.chat-error-notice__action');
|
||||||
|
expect(wrapper.text()).toContain('模型连接异常,请重试');
|
||||||
|
expect(retryButton.text()).toBe('请重试');
|
||||||
|
await retryButton.trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('errorAction')?.[0]?.[0]).toEqual(errorItem);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a check icon after the copy action succeeds', async () => {
|
it('shows a check icon after the copy action succeeds', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const copyAction = vi.fn().mockResolvedValue(true);
|
const copyAction = vi.fn().mockResolvedValue(true);
|
||||||
|
|||||||
@@ -54,20 +54,29 @@ function isHiddenToolName(toolName?: string) {
|
|||||||
const normalizedName = normalizeToolName(toolName);
|
const normalizedName = normalizeToolName(toolName);
|
||||||
return (
|
return (
|
||||||
normalizedName === 'retrieve_knowledge' ||
|
normalizedName === 'retrieve_knowledge' ||
|
||||||
|
normalizedName.startsWith('retrieve_knowledge_') ||
|
||||||
normalizedName === 'context_reload' ||
|
normalizedName === 'context_reload' ||
|
||||||
normalizedName === '__fragment__'
|
normalizedName === '__fragment__'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKnowledgeRetrievalToolName(toolName?: string) {
|
function isKnowledgeRetrievalToolName(toolName?: string) {
|
||||||
return normalizeToolName(toolName) === 'retrieve_knowledge';
|
const normalizedName = normalizeToolName(toolName);
|
||||||
|
return (
|
||||||
|
normalizedName === 'retrieve_knowledge' ||
|
||||||
|
normalizedName.startsWith('retrieve_knowledge_')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlankToolName(toolName?: string) {
|
function isBlankToolName(toolName?: string) {
|
||||||
return !normalizeToolName(toolName);
|
return !normalizeToolName(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function knowledgeRetrievalStatusKey(statusKey?: string) {
|
function knowledgeRetrievalStatusKey(statusKey?: string, roundId?: string) {
|
||||||
|
const normalizedRoundId = normalizeText(roundId).trim();
|
||||||
|
if (normalizedRoundId) {
|
||||||
|
return `knowledge-retrieval:${normalizedRoundId}`;
|
||||||
|
}
|
||||||
return normalizeText(statusKey).trim() || 'knowledge-retrieval';
|
return normalizeText(statusKey).trim() || 'knowledge-retrieval';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,12 +668,18 @@ export const ChatTimelineBuilder = {
|
|||||||
metadata?: ChatTimelineTurnMetadata,
|
metadata?: ChatTimelineTurnMetadata,
|
||||||
) {
|
) {
|
||||||
finishAssistantMessage(items, false, metadata?.roundId);
|
finishAssistantMessage(items, false, metadata?.roundId);
|
||||||
|
let label = '已检索知识库';
|
||||||
|
if (status === 'running') {
|
||||||
|
label = '正在检索知识库';
|
||||||
|
} else if (status === 'error') {
|
||||||
|
label = '知识库检索失败';
|
||||||
|
}
|
||||||
upsertStatus(items, {
|
upsertStatus(items, {
|
||||||
...metadata,
|
...metadata,
|
||||||
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
|
label,
|
||||||
status,
|
status,
|
||||||
statusKey: knowledgeRetrievalStatusKey(statusKey),
|
statusKey: knowledgeRetrievalStatusKey(statusKey, metadata?.roundId),
|
||||||
tone: 'muted',
|
tone: status === 'error' ? 'danger' : 'muted',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user