feat: 支持智能体文档附件与轻量读取

- 建立文档上传、异步读取、对象存储、补偿与聊天绑定闭环

- 按智能体 20K 上下文预算选择文档片段并保留稳定引用

- 统一聊天文件卡片、类型图标、草稿恢复与可靠下载
This commit is contained in:
2026-07-29 01:32:19 +08:00
parent fceedd02cd
commit d45c67a317
72 changed files with 5876 additions and 237 deletions

View File

@@ -0,0 +1,211 @@
package tech.easyflow.agent.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.boot.convert.DataSizeUnit;
import org.springframework.util.unit.DataSize;
import org.springframework.util.unit.DataUnit;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
/**
* Agent 文档附件的 I/O、并发与格式安全边界配置。
*/
@Validated
@ConfigurationProperties(prefix = "easyflow.agent.document")
public class AgentDocumentProperties {
/** 是否启用文档附件。 */
private boolean enabled = true;
/** 单轮最大文档数。 */
@Min(1)
private int maxDocumentsPerTurn = 3;
/** 单轮文档总字节上限。 */
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxTotalBytesPerTurn = DataSize.ofMegabytes(30);
/** 单文档读取超时。 */
@NotNull
private Duration readTimeout = Duration.ofSeconds(30);
/** 未绑定文档保留时间。 */
@NotNull
private Duration tempRetention = Duration.ofHours(24);
/** 读取线程池配置。 */
@Valid
@NotNull
private Reader reader = new Reader();
/** 格式安全边界。 */
@Valid
@NotNull
private Limits limits = new Limits();
/** @return 是否启用文档附件 */
public boolean isEnabled() { return enabled; }
/** @param enabled 是否启用文档附件 */
public void setEnabled(boolean enabled) { this.enabled = enabled; }
/** @return 单轮最大文档数 */
public int getMaxDocumentsPerTurn() { return maxDocumentsPerTurn; }
/** @param maxDocumentsPerTurn 单轮最大文档数 */
public void setMaxDocumentsPerTurn(int maxDocumentsPerTurn) { this.maxDocumentsPerTurn = maxDocumentsPerTurn; }
/** @return 单轮文档总大小 */
public DataSize getMaxTotalBytesPerTurn() { return maxTotalBytesPerTurn; }
/** @param maxTotalBytesPerTurn 单轮文档总大小 */
public void setMaxTotalBytesPerTurn(DataSize maxTotalBytesPerTurn) {
this.maxTotalBytesPerTurn = maxTotalBytesPerTurn;
}
/** @return 读取超时 */
public Duration getReadTimeout() { return readTimeout; }
/** @param readTimeout 读取超时 */
public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; }
/** @return 临时附件保留时间 */
public Duration getTempRetention() { return tempRetention; }
/** @param tempRetention 临时附件保留时间 */
public void setTempRetention(Duration tempRetention) { this.tempRetention = tempRetention; }
/** @return 读取线程池配置 */
public Reader getReader() { return reader; }
/** @param reader 读取线程池配置 */
public void setReader(Reader reader) { this.reader = reader; }
/** @return 格式安全边界 */
public Limits getLimits() { return limits; }
/** @param limits 格式安全边界 */
public void setLimits(Limits limits) { this.limits = limits; }
/**
* 校验持续时间和数据大小均为正值。
*
* @return 配置是否合法
*/
@AssertTrue(message = "文档大小与时间配置必须大于 0")
public boolean isPositiveBoundaries() {
return maxTotalBytesPerTurn != null && maxTotalBytesPerTurn.toBytes() > 0
&& readTimeout != null && !readTimeout.isZero() && !readTimeout.isNegative()
&& tempRetention != null && !tempRetention.isZero() && !tempRetention.isNegative();
}
/**
* 文档读取线程池配置。
*/
public static class Reader {
/** 核心线程数。 */
@Min(1)
private int coreSize = 2;
/** 最大线程数。 */
@Min(1)
private int maxSize = 4;
/** 有界队列容量。 */
@Min(1)
private int queueCapacity = 32;
/** @return 核心线程数 */
public int getCoreSize() { return coreSize; }
/** @param coreSize 核心线程数 */
public void setCoreSize(int coreSize) { this.coreSize = coreSize; }
/** @return 最大线程数 */
public int getMaxSize() { return maxSize; }
/** @param maxSize 最大线程数 */
public void setMaxSize(int maxSize) { this.maxSize = maxSize; }
/** @return 队列容量 */
public int getQueueCapacity() { return queueCapacity; }
/** @param queueCapacity 队列容量 */
public void setQueueCapacity(int queueCapacity) { this.queueCapacity = queueCapacity; }
/**
* 校验最大线程数不小于核心线程数。
*
* @return 配置是否合法
*/
@AssertTrue(message = "文档读取最大线程数不能小于核心线程数")
public boolean isThreadRangeValid() {
return maxSize >= coreSize;
}
}
/**
* 各文档格式的安全边界。
*/
public static class Limits {
/** Office 单文件上限。 */
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize officeMaxBytes = DataSize.ofMegabytes(20);
/** Excel 单文件上限。 */
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize excelMaxBytes = DataSize.ofMegabytes(10);
/** 文本单文件上限。 */
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize textMaxBytes = DataSize.ofMegabytes(5);
/** PDF 最大页数。 */
@Min(1)
private int maxPdfPages = 200;
/** 演示文稿最大幻灯片数。 */
@Min(1)
private int maxSlides = 200;
/** 表格最大工作表数。 */
@Min(1)
private int maxSheets = 20;
/** 表格最大非空单元格数。 */
@Min(1)
private int maxNonEmptyCells = 50_000;
/** 最大展开内容量。 */
@NotNull
@DataSizeUnit(DataUnit.MEGABYTES)
private DataSize maxExpandedBytes = DataSize.ofMegabytes(150);
/** @return Office 单文件上限 */
public DataSize getOfficeMaxBytes() { return officeMaxBytes; }
/** @param officeMaxBytes Office 单文件上限 */
public void setOfficeMaxBytes(DataSize officeMaxBytes) { this.officeMaxBytes = officeMaxBytes; }
/** @return Excel 单文件上限 */
public DataSize getExcelMaxBytes() { return excelMaxBytes; }
/** @param excelMaxBytes Excel 单文件上限 */
public void setExcelMaxBytes(DataSize excelMaxBytes) { this.excelMaxBytes = excelMaxBytes; }
/** @return 文本单文件上限 */
public DataSize getTextMaxBytes() { return textMaxBytes; }
/** @param textMaxBytes 文本单文件上限 */
public void setTextMaxBytes(DataSize textMaxBytes) { this.textMaxBytes = textMaxBytes; }
/** @return 最大 PDF 页数 */
public int getMaxPdfPages() { return maxPdfPages; }
/** @param maxPdfPages 最大 PDF 页数 */
public void setMaxPdfPages(int maxPdfPages) { this.maxPdfPages = maxPdfPages; }
/** @return 最大幻灯片数 */
public int getMaxSlides() { return maxSlides; }
/** @param maxSlides 最大幻灯片数 */
public void setMaxSlides(int maxSlides) { this.maxSlides = maxSlides; }
/** @return 最大工作表数 */
public int getMaxSheets() { return maxSheets; }
/** @param maxSheets 最大工作表数 */
public void setMaxSheets(int maxSheets) { this.maxSheets = maxSheets; }
/** @return 最大非空单元格数 */
public int getMaxNonEmptyCells() { return maxNonEmptyCells; }
/** @param maxNonEmptyCells 最大非空单元格数 */
public void setMaxNonEmptyCells(int maxNonEmptyCells) { this.maxNonEmptyCells = maxNonEmptyCells; }
/** @return 最大展开内容量 */
public DataSize getMaxExpandedBytes() { return maxExpandedBytes; }
/** @param maxExpandedBytes 最大展开内容量 */
public void setMaxExpandedBytes(DataSize maxExpandedBytes) { this.maxExpandedBytes = maxExpandedBytes; }
/**
* 校验所有数据大小为正值。
*
* @return 配置是否合法
*/
@AssertTrue(message = "文档格式大小上限必须大于 0")
public boolean isPositiveSizes() {
return positive(officeMaxBytes) && positive(excelMaxBytes)
&& positive(textMaxBytes) && positive(maxExpandedBytes);
}
private boolean positive(DataSize value) {
return value != null && value.toBytes() > 0;
}
}
}

View File

@@ -0,0 +1,60 @@
package tech.easyflow.agent.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Agent 文档读取独立有界线程池配置。
*/
@Configuration
public class AgentDocumentReaderConfig {
/**
* 创建文档读取线程池;队列满时直接拒绝,避免任务回落到调用线程。
*
* @param properties 文档配置
* @return 文档读取执行器
*/
@Bean(name = "agentDocumentReaderExecutor", destroyMethod = "shutdown")
public ExecutorService agentDocumentReaderExecutor(AgentDocumentProperties properties) {
AgentDocumentProperties.Reader reader = properties.getReader();
return new ThreadPoolExecutor(
reader.getCoreSize(),
reader.getMaxSize(),
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(reader.getQueueCapacity()),
new ReaderThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
/**
* 文档读取线程命名工厂。
*/
private static final class ReaderThreadFactory implements ThreadFactory {
private final AtomicInteger index = new AtomicInteger(1);
/**
* 创建守护读取线程。
*
* @param runnable 读取任务
* @return 命名线程
*/
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName("agent-document-reader-" + index.getAndIncrement());
thread.setDaemon(true);
return thread;
}
}
}

View File

@@ -13,6 +13,10 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@MapperScan("tech.easyflow.agent.mapper")
@ComponentScan("tech.easyflow.agent")
@EnableScheduling
@EnableConfigurationProperties({AgentRuntimeProperties.class, AgentMediaProperties.class})
@EnableConfigurationProperties({
AgentRuntimeProperties.class,
AgentMediaProperties.class,
AgentDocumentProperties.class
})
public class AgentModuleConfig {
}