This commit is contained in:
2026-08-29 13:32:57 +08:00
commit c56aa6e752
81 changed files with 14319 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
package cn.alphaline.smartfactory;
import cn.alphaline.smartfactory.config.AppProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
/**
* 智造申报 Agent 服务入口。
*/
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class SmartFactoryApplication {
/**
* 启动 Spring Boot 应用。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(SmartFactoryApplication.class, args);
}
}

View File

@@ -0,0 +1,141 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* 提供可恢复的 Agent Run 与 AG-UI 事件流接口。
*/
@RestController
@RequestMapping("/api/projects/{projectId}")
public class AgentController {
private final AgentRunService runService;
private final AgentEventService eventService;
/**
* 创建 Agent 控制器。
*
* @param runService Run 服务
* @param eventService 事件服务
*/
public AgentController(AgentRunService runService, AgentEventService eventService) {
this.runService = runService;
this.eventService = eventService;
}
/**
* 启动材料检验与规划 Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return Run
*/
@PostMapping("/runs/material-check")
public AgentRunService.RunView startMaterialCheck(@PathVariable UUID projectId, Principal principal) {
return runService.startMaterialCheck(projectId, principal);
}
/**
* 确认材料检验结果并进入规划生成。
*
* @param projectId 项目 ID
* @param response 材料缺口处理结果
* @param principal 当前用户
* @return 规划 Run
*/
@PostMapping("/material/confirm")
public AgentRunService.RunView confirmMaterials(
@PathVariable UUID projectId,
@RequestBody JsonNode response,
Principal principal) {
return runService.confirmMaterials(projectId, response, principal);
}
/**
* 启动确认后的自动编写 Run。
*
* @param projectId 项目 ID
* @return Run
*/
@PostMapping("/runs/writing")
public AgentRunService.RunView startWriting(@PathVariable UUID projectId) {
return runService.startWriting(projectId);
}
/**
* 立即停止当前 Agent Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 已中断 Run
*/
@PostMapping("/runs/stop")
public AgentRunService.RunView stop(@PathVariable UUID projectId, Principal principal) {
return runService.stop(projectId, principal);
}
/**
* 从已中断位置继续 Agent Run。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新恢复 Run
*/
@PostMapping("/runs/resume")
public AgentRunService.RunView resume(@PathVariable UUID projectId, Principal principal) {
return runService.resume(projectId, principal);
}
/**
* 返回最近 Run。
*
* @param projectId 项目 ID
* @return 最近 Run不存在时返回 204
*/
@GetMapping("/runs/latest")
public ResponseEntity<AgentRunService.RunView> latest(@PathVariable UUID projectId) {
AgentRunService.RunView run = runService.latest(projectId);
return run == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(run);
}
/**
* 通过游标读取持久化事件。
*
* @param projectId 项目 ID
* @param after 最后已接收事件序号
* @return 增量事件
*/
@GetMapping("/events")
public List<AgentEventService.EventView> events(
@PathVariable UUID projectId,
@RequestParam(defaultValue = "0") long after) {
return eventService.listAfter(projectId, after, 1000);
}
/**
* 以 NDJSON 持续输出新增事件;页面刷新可携带游标恢复。
*
* @param projectId 项目 ID
* @param after 最后已接收事件序号
* @return 持续增量事件流
*/
@GetMapping(path = "/events/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<AgentEventService.EventView> stream(
@PathVariable UUID projectId,
@RequestParam(defaultValue = "0") long after) {
return eventService.streamAfter(projectId, after);
}
}

View File

@@ -0,0 +1,192 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
/**
* 持久化并查询项目级 AG-UI 事件。
*/
@Service
public class AgentEventService {
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
private final Map<UUID, Sinks.Many<EventView>> liveStreams = new ConcurrentHashMap<>();
/**
* 创建事件服务。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
*/
public AgentEventService(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
/**
* 先持久化一个事件,再将其返回给流式接口。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param eventType AG-UI 事件类型
* @param payload 事件负载
* @return 已分配全局序号的事件
*/
public EventView append(UUID projectId, UUID runId, String eventType, Object payload) {
JsonNode value = objectMapper.valueToTree(payload);
ObjectNode object = value.isObject()
? (ObjectNode) value
: objectMapper.createObjectNode().set("value", value);
EventView event = jdbc.sql("""
INSERT INTO app.agent_event(project_id, run_id, event_type, event_id, payload)
VALUES (:projectId, :runId, :eventType, :eventId, CAST(:payload AS jsonb))
RETURNING id, project_id, run_id, event_type, payload, created_at
""")
.param("projectId", projectId)
.param("runId", runId)
.param("eventType", eventType)
.param("eventId", UUID.randomUUID().toString())
.param("payload", object.toString())
.query(this::mapEvent)
.single();
publishAfterCommit(event);
return event;
}
/**
* 按游标查询增量事件。
*
* @param projectId 项目 ID
* @param afterId 排除的最后事件序号
* @param limit 最大返回数量
* @return 有序事件
*/
public List<EventView> listAfter(UUID projectId, long afterId, int limit) {
return jdbc.sql("""
SELECT id, project_id, run_id, event_type, payload, created_at
FROM app.agent_event
WHERE project_id = :projectId AND id > :afterId
ORDER BY id
LIMIT :limit
""")
.param("projectId", projectId)
.param("afterId", Math.max(0, afterId))
.param("limit", Math.clamp(limit, 1, 1000))
.query(this::mapEvent)
.list();
}
/**
* 先回放数据库增量,再持续推送当前进程产生的新事件。
*
* @param projectId 项目 ID
* @param afterId 排除的最后事件序号
* @return 带轻量心跳的事件流
*/
public Flux<EventView> streamAfter(UUID projectId, long afterId) {
return Flux.defer(() -> {
AtomicLong cursor = new AtomicLong(Math.max(0, afterId));
Sinks.Many<EventView> sink = liveStreams.computeIfAbsent(
projectId, ignored -> Sinks.many().replay().limit(2_048));
Mono<List<EventView>> first = queryBatch(projectId, cursor.get());
Flux<EventView> backlog = first
.expand(batch -> batch.size() == 1_000
? queryBatch(projectId, batch.getLast().id())
: Mono.empty())
.flatMapIterable(batch -> batch);
Flux<EventView> events = Flux.concat(backlog, sink.asFlux())
.filter(event -> event.id() > cursor.get())
.doOnNext(event -> cursor.set(event.id()));
Flux<EventView> heartbeat = Flux.interval(java.time.Duration.ofSeconds(15))
.map(tick -> new EventView(
0,
projectId,
null,
"HEARTBEAT",
objectMapper.createObjectNode(),
OffsetDateTime.now()));
return Flux.merge(events, heartbeat)
.doFinally(signal -> {
if (sink.currentSubscriberCount() == 0) {
liveStreams.remove(projectId, sink);
}
});
});
}
private Mono<List<EventView>> queryBatch(UUID projectId, long afterId) {
return Mono.fromCallable(() -> listAfter(projectId, afterId, 1_000))
.subscribeOn(Schedulers.boundedElastic());
}
private void publishAfterCommit(EventView event) {
Runnable publish = () -> {
Sinks.Many<EventView> sink = liveStreams.get(event.projectId());
if (sink != null) {
Sinks.EmitResult result = sink.tryEmitNext(event);
if (result == Sinks.EmitResult.FAIL_NON_SERIALIZED) {
sink.emitNext(event, Sinks.EmitFailureHandler.busyLooping(java.time.Duration.ofMillis(100)));
}
}
};
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
publish.run();
}
});
} else {
publish.run();
}
}
private EventView mapEvent(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
try {
return new EventView(
rs.getLong("id"),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("event_type"),
objectMapper.readTree(rs.getString("payload")),
rs.getObject("created_at", OffsetDateTime.class));
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new java.sql.SQLException("Agent 事件 JSON 无法解析", exception);
}
}
/**
* 前端可恢复事件视图。
*
* @param id 项目全局事件序号
* @param projectId 项目 ID
* @param runId Run ID心跳为空
* @param type 事件类型
* @param payload AG-UI 事件负载
* @param createdAt 产生时间
*/
public record EventView(
long id,
UUID projectId,
UUID runId,
String type,
JsonNode payload,
OffsetDateTime createdAt) {
}
}

View File

@@ -0,0 +1,263 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.project.ProjectFileService;
import cn.alphaline.smartfactory.project.ProjectService;
import cn.alphaline.smartfactory.skill.SkillService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.agentscope.core.agui.event.AguiEvent;
import io.agentscope.core.agui.model.AguiMessage;
import io.agentscope.core.agui.model.RunAgentInput;
import io.agentscope.core.model.ModelHttpException;
import io.agentscope.core.model.transport.HttpTransportException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* 运行 Harness Agent并把 AG-UI 增量转换为可恢复事件。
*/
@Service
public class AgentExecutionService {
private static final Pattern HOST_PATH = Pattern.compile("/Users/[^\\s\"')]+");
private static final Pattern API_KEY = Pattern.compile("sk-[A-Za-z0-9_-]{8,}");
static final int MAX_MODEL_RECONNECTS = 5;
private final ObjectMapper objectMapper;
private final AgentFactory agentFactory;
private final AgentEventService eventService;
private final ProjectFileService fileService;
private final SkillService skillService;
/**
* 创建 Agent 执行服务。
*
* @param objectMapper JSON 映射器
* @param agentFactory Agent 工厂
* @param eventService 事件服务
* @param fileService 工作区服务
* @param skillService Skill 服务
*/
public AgentExecutionService(
ObjectMapper objectMapper,
AgentFactory agentFactory,
AgentEventService eventService,
ProjectFileService fileService,
SkillService skillService) {
this.objectMapper = objectMapper;
this.agentFactory = agentFactory;
this.eventService = eventService;
this.fileService = fileService;
this.skillService = skillService;
}
/**
* 执行一个可停止、可重连的 Agent 流。
*
* @param project 当前项目
* @param run 当前 Run
* @param prompt 本轮提示词
* @param stopSignal 用户停止信号
* @param ensureRunning 终态检查
* @param interrupted 中断状态查询
*/
public void execute(
ProjectService.ProjectView project,
AgentRunService.RunView run,
String prompt,
Mono<Void> stopSignal,
Runnable ensureRunning,
BooleanSupplier interrupted) {
EventAccumulator accumulator = new EventAccumulator(project.id(), run.id());
for (int reconnects = 0; ; reconnects++) {
ensureRunning.run();
String attemptPrompt = reconnects == 0 ? prompt : """
模型连接刚刚中断。请恢复同一线程的会话状态,读取 MEMORY.md 和工作区已有成果,
检查未完成的输出后从中断处继续;复用已经完成的工具结果,不要重复已完成操作。
""";
RunAgentInput input = RunAgentInput.builder()
.threadId(project.threadId())
.runId(run.id().toString())
.messages(List.of(AguiMessage.userMessage(UUID.randomUUID().toString(), attemptPrompt)))
.build();
try (AgentFactory.AgentHandle handle = agentFactory.create(project.id(), skillService.enabledNames())) {
handle.adapter().run(input)
.takeUntilOther(stopSignal)
.bufferTimeout(64, Duration.ofMillis(120))
.doOnNext(accumulator::accept)
.blockLast();
accumulator.flush();
ensureRunning.run();
return;
} catch (RuntimeException exception) {
accumulator.flush();
if (interrupted.getAsBoolean()) {
throw new RunInterruptedException();
}
if (reconnects >= MAX_MODEL_RECONNECTS || !isRetryableModelFailure(exception)) {
throw exception;
}
int attempt = reconnects + 1;
eventService.append(project.id(), run.id(), "MODEL_RETRY", Map.of(
"attempt", attempt, "maxAttempts", MAX_MODEL_RECONNECTS));
pauseBeforeReconnect(attempt, interrupted);
}
}
}
/**
* 判断异常链是否属于可重试的模型连接故障。
*
* @param failure 模型调用异常
* @return 是否允许重连
*/
static boolean isRetryableModelFailure(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof HttpTransportException transport && transport.isRetryable()) {
return true;
}
if (current instanceof ModelHttpException http && http.isRetryableHttpStatus()) {
return true;
}
if (current.getCause() == current) {
break;
}
current = current.getCause();
}
return false;
}
private void pauseBeforeReconnect(int attempt, BooleanSupplier interrupted) {
try {
Thread.sleep(Math.min(8_000L, 500L << (attempt - 1)));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
if (interrupted.getAsBoolean()) {
throw new RunInterruptedException();
}
throw new IllegalStateException("模型重连等待被中断", exception);
}
}
/**
* 标识正常的用户中断。
*/
static final class RunInterruptedException extends RuntimeException {
}
/**
* 跨 Reactor 批次合并连续 token并过滤 Adapter 自带的重复生命周期事件。
*/
private final class EventAccumulator {
private final UUID projectId;
private final UUID runId;
private ObjectNode pending;
private String pendingType;
private String pendingKey;
private String pendingField;
private long lastFlushNanos = System.nanoTime();
private EventAccumulator(UUID projectId, UUID runId) {
this.projectId = projectId;
this.runId = runId;
}
private void accept(List<AguiEvent> batch) {
for (AguiEvent event : batch) {
accept(event);
}
if (System.nanoTime() - lastFlushNanos >= Duration.ofMillis(400).toNanos()) {
flush();
}
}
private void accept(AguiEvent event) {
String type = event.getType().name();
if (type.equals("RUN_STARTED") || type.equals("RUN_FINISHED") || type.equals("RUN_ERROR")) {
return;
}
ObjectNode payload = (ObjectNode) sanitize(
objectMapper.valueToTree(event), fileService.projectRoot(projectId).toString());
if (type.equals("TOOL_CALL_RESULT") && payload.path("content").isTextual()) {
payload.put("content", stripInlineImageData(payload.path("content").asText()));
}
String field = switch (type) {
case "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_CHUNK",
"REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_CHUNK" ->
payload.has("delta") ? "delta" : "content";
case "TOOL_CALL_ARGS", "TOOL_CALL_CHUNK" -> payload.has("delta") ? "delta" : "args";
default -> null;
};
String key = payload.path("messageId").asText(payload.path("toolCallId").asText(""));
if (field != null && pending != null && type.equals(pendingType)
&& key.equals(pendingKey) && field.equals(pendingField)) {
pending.put(field, pending.path(field).asText() + payload.path(field).asText());
return;
}
flush();
if (field == null) {
eventService.append(projectId, runId, type, payload);
} else {
pending = payload;
pendingType = type;
pendingKey = key;
pendingField = field;
}
}
private JsonNode sanitize(JsonNode node, String workspaceRoot) {
if (node.isTextual()) {
String text = node.textValue().replace(workspaceRoot, "工作区");
text = HOST_PATH.matcher(text).replaceAll("内部路径");
return objectMapper.getNodeFactory().textNode(API_KEY.matcher(text).replaceAll("已隐藏凭证"));
}
if (node instanceof ObjectNode object) {
object.properties().forEach(entry -> object.set(
entry.getKey(), sanitize(entry.getValue(), workspaceRoot)));
} else if (node instanceof ArrayNode array) {
for (int index = 0; index < array.size(); index++) {
array.set(index, sanitize(array.get(index), workspaceRoot));
}
}
return node;
}
private void flush() {
if (pending != null) {
eventService.append(projectId, runId, pendingType, pending);
pending = null;
pendingType = null;
pendingKey = null;
pendingField = null;
}
lastFlushNanos = System.nanoTime();
}
}
/**
* 从持久化 AG-UI 工具结果中移除已经传给模型的 Base64 图片,保留结构化文本元数据。
*
* @param content AG-UI 工具结果文本
* @return 适合数据库与浏览器恢复的精简结果
*/
static String stripInlineImageData(String content) {
int imageStart = content.indexOf("\n{\"type\":\"image\"");
if (content.startsWith("document_view_result=") && imageStart > 0) {
return content.substring(0, imageStart);
}
return content.lines()
.filter(line -> !((line.contains("\"mediaType\"") || line.contains("\"media_type\""))
&& line.contains("\"data\"")))
.collect(java.util.stream.Collectors.joining("\n"));
}
}

View File

@@ -0,0 +1,320 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.config.AppProperties;
import cn.alphaline.smartfactory.model.ModelService;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agui.adapter.AguiAdapterConfig;
import io.agentscope.core.agui.adapter.AguiAgentAdapter;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.AgentSkillRepository;
import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.extensions.model.openai.OpenAIChatModel;
import io.agentscope.harness.agent.IsolationScope;
import io.agentscope.harness.agent.HarnessAgent;
import io.agentscope.harness.agent.memory.compaction.CompactionConfig;
import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig;
import io.agentscope.harness.agent.sandbox.WorkspaceSpec;
import io.agentscope.harness.agent.sandbox.impl.docker.DockerFilesystemSpec;
import io.agentscope.harness.agent.sandbox.layout.BindMountEntry;
import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry;
import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
/**
* 按当前模型、Skill 和项目工作区创建短生命周期 Harness Agent。
*/
@Component
public class AgentFactory {
private final ModelService modelService;
private final PostgresSkillRepository skillRepository;
private final ProjectFileService fileService;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final String systemPrompt;
private final String compactionPrompt;
/**
* 创建 Agent 工厂。
*
* @param modelService 模型配置服务
* @param skillRepository AgentScope Skill 仓库
* @param fileService 项目工作区服务
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
public AgentFactory(
ModelService modelService,
PostgresSkillRepository skillRepository,
ProjectFileService fileService,
AppProperties properties,
ObjectMapper objectMapper) {
this.modelService = modelService;
this.skillRepository = skillRepository;
this.fileService = fileService;
this.properties = properties;
this.objectMapper = objectMapper;
this.systemPrompt = readPrompt("prompts/smart-factory-agent-system.md", "Agent 全局提示词");
this.compactionPrompt = readPrompt("prompts/smart-factory-compaction.md", "上下文压缩提示词");
}
/**
* 创建开启 AG-UI 推理和工具事件的 Harness 适配器。
*
* @param projectId 项目 ID
* @param enabledSkills 当前启用 Skill
* @return 需要在流结束后关闭的 Agent 句柄
*/
public AgentHandle create(UUID projectId, String[] enabledSkills) {
ModelService.ModelSecret model = modelService.defaultModelSecret();
OpenAIChatModel chatModel = OpenAIChatModel.builder()
.apiKey(model.apiKey())
.baseUrl(model.baseUrl())
.modelName(model.modelId())
.stream(true)
.build();
fileService.ensureWorkspace(projectId);
Path projectRoot = fileService.projectRoot(projectId);
Map<String, String> environment = new HashMap<>();
environment.put("DASHSCOPE_API_KEY", readOptionalKey(properties.dashscopeKeyFile()));
environment.put("PATH", "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin");
environment.put("NODE_PATH", "/opt/agent-runtime/node_modules");
environment.put("SKILL_SESSION_ID", "project-" + projectId);
WorkspaceSpec workspace = new WorkspaceSpec();
Map<String, WorkspaceEntry> entries = new LinkedHashMap<>();
entries.put("inputs", mount(projectRoot.resolve("inputs"), true));
entries.put("work", mount(projectRoot.resolve("work"), false));
entries.put("references", mount(projectRoot.resolve("references"), false));
entries.put("artifacts", mount(projectRoot.resolve("work/candidates"), false));
workspace.setEntries(entries);
workspace.setEnvironment(environment);
Path snapshotRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("sandbox-snapshots");
try {
Files.createDirectories(snapshotRoot);
} catch (IOException exception) {
throw new IllegalStateException("无法创建 Agent 沙箱快照目录", exception);
}
DockerFilesystemSpec filesystem = new DockerFilesystemSpec()
.image(properties.sandboxImage())
.workspaceRoot("/workspace")
.environment(environment)
.memorySizeBytes(2L * 1024 * 1024 * 1024)
.cpuCount(2L)
.exposedPorts()
.network(properties.sandboxNetwork())
.additionalRunArgs(
"--pids-limit=256",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--stop-signal=SIGKILL")
.snapshotSpec(new LocalSnapshotSpec(snapshotRoot))
.workspaceSpec(workspace);
filesystem.isolationScope(IsolationScope.SESSION);
CompactionConfig compaction = compactionFor(model.contextWindow(), compactionPrompt);
Toolkit toolkit = new Toolkit();
DocumentViewTool documentView = new DocumentViewTool(objectMapper);
toolkit.registerTool(documentView);
HarnessAgent agent = HarnessAgent.builder()
.name("smart-factory-agent")
.description("智能工厂申报书规划、编写与评审 Agent")
.sysPrompt(systemPrompt)
.model(chatModel)
.toolkit(toolkit)
.workspace(projectRoot)
.filesystem(filesystem)
.skillRepository(new EnabledSkillRepository(skillRepository, Set.copyOf(Arrays.asList(enabledSkills))))
.compaction(compaction)
.toolResultEviction(ToolResultEvictionConfig.defaults())
.maxContextTokens(model.contextWindow())
.maxIters(96)
.enableAgentTracingLog(false)
.disableSubagents()
.build();
agent.getToolkit().registerTool(new PagedReadFileTool(
agent.getWorkspaceManager().getFilesystem(),
WorkspacePathNormalizer.of("/workspace"),
objectMapper));
documentView.bind(agent);
AguiAdapterConfig config = AguiAdapterConfig.builder()
.enableReasoning(true)
.emitToolCallArgs(true)
.emitStateEvents(false)
.runTimeout(properties.runTimeout())
.defaultAgentId("smart-factory-agent")
.build();
return new AgentHandle(new AguiAgentAdapter(agent, config), agent);
}
/**
* 按模型窗口构造仅由 Token 触发的压缩配置。
*
* @param contextWindow 模型上下文窗口
* @param summaryPrompt 压缩摘要约束
* @return AgentScope 压缩配置
*/
static CompactionConfig compactionFor(int contextWindow, String summaryPrompt) {
int triggerTokens = Math.max(1, (int) Math.floor(contextWindow * 0.90d));
int keepTokensMin = Math.max(1_024, Math.min(4_000, contextWindow / 8));
int keepTokensMax = Math.max(keepTokensMin, Math.min(16_000, contextWindow / 5));
return CompactionConfig.builder()
.triggerMessages(0)
.triggerTokens(triggerTokens)
.reserved(Math.max(1_024, contextWindow / 10))
.keepTokensMin(keepTokensMin)
.keepTokensMax(keepTokensMax)
.keepTokensRatio(0.18d)
.summaryPrompt(summaryPrompt)
.flushBeforeCompact(true)
.offloadBeforeCompact(true)
.build();
}
/**
* 将 AgentScope 默认的 name_source Skill ID 规范化为业务名称,同时保留完整 Skill 信息。
*
* @param skill 仓库返回的 Skill
* @return 使用业务名称作为调用 ID 的 Skill
*/
static AgentSkill canonicalSkill(AgentSkill skill) {
return new AgentSkill(
skill.getMetadata(),
skill.getSkillContent(),
skill.getResources(),
skill.getSource(),
skill.getOriginDir().orElse(null)) {
/** {@inheritDoc} */
@Override
public String getSkillId() {
return getName();
}
};
}
private BindMountEntry mount(Path hostPath, boolean readOnly) {
BindMountEntry entry = new BindMountEntry();
entry.setHostPath(hostPath.toAbsolutePath().normalize().toString());
entry.setReadOnly(readOnly);
return entry;
}
private String readPrompt(String path, String label) {
try {
return new ClassPathResource(path)
.getContentAsString(StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new IllegalStateException("无法读取" + label, exception);
}
}
private String readOptionalKey(java.nio.file.Path path) {
try {
return Files.isRegularFile(path) ? Files.readString(path, StandardCharsets.UTF_8).trim() : "";
} catch (IOException exception) {
throw new IllegalStateException("无法读取百炼知识库 Key", exception);
}
}
/**
* 绑定 AG-UI 适配器与其拥有的沙箱 Agent 生命周期。
*
* @param adapter AG-UI 适配器
* @param agent Harness Agent
*/
public record AgentHandle(AguiAgentAdapter adapter, HarnessAgent agent) implements AutoCloseable {
/**
* 结束 Agent 并释放 Docker 沙箱资源。
*/
@Override
public void close() {
agent.close();
}
}
/**
* 只向 Agent 暴露管理员启用的 Skill所有写操作继续委托原仓库。
*/
private static final class EnabledSkillRepository implements AgentSkillRepository {
private final AgentSkillRepository delegate;
private final Set<String> enabled;
private EnabledSkillRepository(AgentSkillRepository delegate, Set<String> enabled) {
this.delegate = delegate;
this.enabled = enabled;
}
@Override
public AgentSkill getSkill(String name) {
AgentSkill skill = enabled.contains(name) ? delegate.getSkill(name) : null;
return skill == null ? null : canonicalSkill(skill);
}
@Override
public List<String> getAllSkillNames() {
return delegate.getAllSkillNames().stream().filter(enabled::contains).toList();
}
@Override
public List<AgentSkill> getAllSkills() {
return delegate.getAllSkills().stream()
.filter(skill -> enabled.contains(skill.getName()))
.map(AgentFactory::canonicalSkill)
.toList();
}
@Override
public boolean save(List<AgentSkill> skills, boolean overwrite) {
return delegate.save(skills, overwrite);
}
@Override
public boolean delete(String name) {
return delegate.delete(name);
}
@Override
public boolean skillExists(String name) {
return enabled.contains(name) && delegate.skillExists(name);
}
@Override
public AgentSkillRepositoryInfo getRepositoryInfo() {
return delegate.getRepositoryInfo();
}
@Override
public String getSource() {
return delegate.getSource();
}
@Override
public void setWriteable(boolean writeable) {
delegate.setWriteable(writeable);
}
@Override
public boolean isWriteable() {
return delegate.isWriteable();
}
}
}

View File

@@ -0,0 +1,179 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.IntSummaryStatistics;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* 读取并校验 Agent 交给业务流程的结构化文件。
*/
@Service
public class AgentOutputService {
private static final Pattern YEAR_RANGE = Pattern.compile("(20\\d{2})\\D+(20\\d{2})");
private static final Pattern NUMBER = Pattern.compile("(\\d+)");
private final ObjectMapper objectMapper;
private final ProjectFileService fileService;
/**
* 创建 Agent 输出服务。
*
* @param objectMapper JSON 映射器
* @param fileService 工作区服务
*/
public AgentOutputService(ObjectMapper objectMapper, ProjectFileService fileService) {
this.objectMapper = objectMapper;
this.fileService = fileService;
}
/**
* 读取并校验材料检验结果。
*
* @param projectId 项目 ID
* @return 材料检验对象
* @throws IOException 文件无法读取时抛出
*/
public ObjectNode readMaterialCheck(UUID projectId) throws IOException {
Path path = fileService.safeProjectPath(projectId, "work/facts/material-check.json");
if (!Files.isRegularFile(path)) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"MATERIAL_CHECK_MISSING",
"Agent 未生成材料检验结果,请重试");
}
JsonNode value = objectMapper.readTree(path.toFile());
if (!(value instanceof ObjectNode report)
|| !hasText(report, "summary")
|| !report.path("completeness").canConvertToInt()
|| !report.path("confirmedFacts").isArray()
|| !report.path("missingItems").isArray()) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"MATERIAL_CHECK_INVALID",
"Agent 生成的材料检验结果结构不完整,请重试");
}
return report;
}
/**
* 读取并校验 Agent 建议规划。
*
* @param projectId 项目 ID
* @return 建设规划对象
* @throws IOException 文件无法读取时抛出
*/
public ObjectNode readProposedPlan(UUID projectId) throws IOException {
Path path = fileService.safeProjectPath(projectId, "work/plans/proposed-plan.json");
if (!Files.isRegularFile(path)) {
throw new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"PLAN_OUTPUT_MISSING",
"Agent 未生成结构化建设规划,请重试材料检验");
}
JsonNode value = objectMapper.readTree(path.toFile());
if (!(value instanceof ObjectNode plan)) {
throw invalidPlan();
}
normalizePlanningYears(plan);
if (!hasText(plan, "coreDirection")
|| !hasText(plan, "collaborationDirection")
|| !hasText(plan, "factoryName")
|| !plan.path("planningYears").canConvertToInt()
|| !hasText(plan, "investmentRange")
|| !hasText(plan, "applicationLevel")
|| !plan.path("scenarios").isArray()
|| plan.path("scenarios").isEmpty()
|| !plan.path("aiScenarioCount").canConvertToInt()
|| !plan.path("assumptions").isArray()) {
throw invalidPlan();
}
plan.put("scenarioCount", plan.path("scenarios").size());
return plan;
}
/**
* 校验用户确认后的规划满足编写最小结构。
*
* @param plan 用户确认规划
*/
public void validateConfirmedPlan(JsonNode plan) {
if (plan == null || !plan.isObject()
|| !hasText(plan, "coreDirection")
|| !hasText(plan, "collaborationDirection")
|| !hasText(plan, "factoryName")
|| !plan.path("planningYears").canConvertToInt()
|| plan.path("planningYears").asInt() < 1
|| !hasText(plan, "investmentRange")
|| !plan.path("scenarioCount").canConvertToInt()
|| plan.path("scenarioCount").asInt() < 1
|| !plan.path("aiScenarioCount").canConvertToInt()
|| plan.path("aiScenarioCount").asInt() < 0
|| plan.path("aiScenarioCount").asInt() > plan.path("scenarioCount").asInt()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PLAN_INVALID", "请完整填写并检查建设规划");
}
}
/**
* 将模型可能输出的年份数组或文本区间归一化为规划年数。
*
* @param plan 待归一化的规划对象
*/
static void normalizePlanningYears(ObjectNode plan) {
JsonNode value = plan.path("planningYears");
if (value.canConvertToInt()) {
return;
}
if (value.isArray()) {
IntSummaryStatistics years = new IntSummaryStatistics();
value.forEach(year -> {
Matcher matcher = NUMBER.matcher(year.asText());
if (matcher.find()) {
years.accept(Integer.parseInt(matcher.group(1)));
}
});
if (years.getCount() > 0) {
plan.put("planningPeriod", years.getMin() == years.getMax()
? Integer.toString(years.getMin())
: years.getMin() + "-" + years.getMax());
plan.put("planningYears", years.getMax() - years.getMin() + 1);
}
return;
}
String text = value.asText();
Matcher range = YEAR_RANGE.matcher(text);
if (range.find()) {
int years = Integer.parseInt(range.group(2)) - Integer.parseInt(range.group(1)) + 1;
plan.put("planningPeriod", text);
plan.put("planningYears", Math.max(1, years));
return;
}
Matcher number = NUMBER.matcher(text);
if (number.find()) {
plan.put("planningPeriod", text);
plan.put("planningYears", Math.max(1, Integer.parseInt(number.group(1))));
}
}
private boolean hasText(JsonNode value, String field) {
return value.path(field).isTextual() && !value.path(field).asText().isBlank();
}
private ApiException invalidPlan() {
return new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"PLAN_OUTPUT_INVALID",
"Agent 生成的建设规划结构不完整,请重试材料检验");
}
}

View File

@@ -0,0 +1,684 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.artifact.ArtifactService;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import cn.alphaline.smartfactory.project.ProjectService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import reactor.core.publisher.Sinks;
/**
* 驱动材料检验、规划 Ask 和自动编写 Run。
*/
@Service
public class AgentRunService {
private static final Logger log = LoggerFactory.getLogger(AgentRunService.class);
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
private final AgentExecutionService executionService;
private final AgentOutputService outputService;
private final AgentRunStore runStore;
private final AgentEventService eventService;
private final ProjectService projectService;
private final ProjectFileService fileService;
private final UserService userService;
private final ArtifactService artifactService;
private final ExecutorService executor;
private final TransactionTemplate transactions;
private final ConcurrentMap<UUID, RunControl> activeRuns = new ConcurrentHashMap<>();
/**
* 创建 Agent Run 服务。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
* @param executionService Agent 执行服务
* @param outputService Agent 结构化输出服务
* @param runStore Run 状态存储
* @param eventService 事件服务
* @param projectService 项目服务
* @param fileService 材料与工作区服务
* @param userService 用户服务
* @param artifactService 产物服务
* @param applicationExecutor 虚拟线程执行器
* @param transactions 编程式事务模板
*/
public AgentRunService(
JdbcClient jdbc,
ObjectMapper objectMapper,
AgentExecutionService executionService,
AgentOutputService outputService,
AgentRunStore runStore,
AgentEventService eventService,
ProjectService projectService,
ProjectFileService fileService,
UserService userService,
ArtifactService artifactService,
ExecutorService applicationExecutor,
TransactionTemplate transactions) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.executionService = executionService;
this.outputService = outputService;
this.runStore = runStore;
this.eventService = eventService;
this.projectService = projectService;
this.fileService = fileService;
this.userService = userService;
this.artifactService = artifactService;
this.executor = applicationExecutor;
this.transactions = transactions;
}
/**
* 后台启动材料检验。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新 Run
*/
@Transactional
public RunView startMaterialCheck(UUID projectId, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = runStore.create(projectId, "INITIAL", null);
projectService.updateStatus(projectId, "MATERIAL_CHECK");
afterCommit(run.id(), () -> executeMaterialRun(project, run, false));
return run;
}
/**
* 确认材料检验结果并后台生成建设规划。
*
* @param projectId 项目 ID
* @param response 用户对材料缺口的处理结果
* @param principal 当前用户
* @return 新规划 Run
*/
@Transactional
public RunView confirmMaterials(UUID projectId, JsonNode response, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView waiting = runStore.requireWaiting(projectId, "material_check");
if (!response.isObject() || !response.path("decisions").isArray()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MATERIAL_RESPONSE_INVALID", "请确认每项材料缺口");
}
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", response);
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
projectService.updateStatus(projectId, "PLANNING");
afterCommit(run.id(), () -> executePlanningRun(project, run, userId, response, false));
return run;
}
/**
* 在一个事务中确认规划并启动自动编写,重复提交返回既有结果。
*
* @param projectId 项目 ID
* @param planId 规划 ID
* @param confirmedPlan 用户确认后的规划
* @param principal 当前用户
* @return 已确认规划及编写 Run
*/
@Transactional
public ConfirmPlanResult confirmPlanAndStartWriting(
UUID projectId,
UUID planId,
JsonNode confirmedPlan,
Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView current = projectService.currentPlan(projectId);
if (current != null && current.id().equals(planId) && "CONFIRMED".equals(current.status())) {
RunView existing = latest(projectId);
if (existing != null && !"WAITING_INPUT".equals(existing.status())) {
return new ConfirmPlanResult(current, existing);
}
}
outputService.validateConfirmedPlan(confirmedPlan);
RunView waiting = runStore.requireWaiting(projectId, "planning");
ProjectService.PlanView plan = projectService.confirmPlan(projectId, planId, confirmedPlan, principal);
eventService.append(projectId, waiting.id(), "ASK_RESPONDED", Map.of("planId", planId));
runStore.completeWaiting(waiting.id());
RunView run = runStore.create(projectId, "RESUME", waiting.id());
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
return new ConfirmPlanResult(plan, run);
}
/**
* 恢复已确认但尚未完成的自动编写任务。
*
* @param projectId 项目 ID
* @return 编写 Run
*/
@Transactional
public RunView startWriting(UUID projectId) {
ProjectService.ProjectView project = projectService.require(projectId);
ProjectService.PlanView plan = projectService.currentPlan(projectId);
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "请先确认建设规划");
}
RunView latest = latest(projectId);
if (latest != null && "RUNNING".equals(latest.status())) {
return latest;
}
if (latest != null && "WAITING_INPUT".equals(latest.status())) {
runStore.completeWaiting(latest.id());
}
RunView run = runStore.create(projectId, "RESUME", latest == null ? null : latest.id());
projectService.updateStatus(projectId, "WRITING");
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, false));
return run;
}
/**
* 立即停止当前 Run并保留项目阶段和工作区成果。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 已中断的 Run
*/
@Transactional
public RunView stop(UUID projectId, Principal principal) {
projectService.require(projectId);
userService.requireUserId(principal.getName());
RunView run = latest(projectId);
if (run == null || !"RUNNING".equals(run.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_ACTIVE", "当前没有正在执行的任务");
}
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'USER_STOPPED', error_message = '用户已停止运行',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated);
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "CANCELLED"));
onCommit(() -> {
RunControl control = activeRuns.get(run.id());
if (control != null) {
control.cancel();
}
});
return runStore.require(run.id());
}
/**
* 从已中断 Run 的原阶段继续,复用同一线程状态和工作区成果。
*
* @param projectId 项目 ID
* @param principal 当前用户
* @return 新的恢复 Run
*/
@Transactional
public RunView resume(UUID projectId, Principal principal) {
ProjectService.ProjectView project = projectService.require(projectId);
UUID userId = userService.requireUserId(principal.getName());
RunView interrupted = latest(projectId);
if (interrupted == null || !"INTERRUPTED".equals(interrupted.status())) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_NOT_INTERRUPTED", "当前没有可继续的任务");
}
String phase = runStore.interruptedPhase(interrupted, project);
RunView run = runStore.create(projectId, "RESUME", interrupted.id());
projectService.updateStatus(projectId, phase);
switch (phase) {
case "MATERIAL_CHECK" -> afterCommit(
run.id(), () -> executeMaterialRun(project, run, true));
case "PLANNING" -> {
JsonNode materialResponse = runStore.latestMaterialResponse(projectId);
afterCommit(run.id(), () -> executePlanningRun(
project, run, userId, materialResponse, true));
}
case "WRITING" -> {
ProjectService.PlanView plan = projectService.currentPlan(projectId);
if (plan == null || !"CONFIRMED".equals(plan.status())) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_NOT_CONFIRMED", "无法恢复:建设规划尚未确认");
}
afterCommit(run.id(), () -> executeWritingRun(project, plan, run, true));
}
default -> throw new ApiException(
HttpStatus.CONFLICT, "RUN_PHASE_UNKNOWN", "无法识别中断前的执行阶段");
}
return run;
}
/**
* 返回项目最近一次 Run。
*
* @param projectId 项目 ID
* @return Run未执行时为空
*/
public RunView latest(UUID projectId) {
return runStore.latest(projectId);
}
private void executeMaterialRun(ProjectService.ProjectView project, RunView run, boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "MATERIAL_CHECK"));
List<ProjectFileService.FileView> files = fileService.list(project.id());
String fileSummary = files.isEmpty()
? "没有企业材料,当前仅有企业名称。"
: files.stream().map(file -> file.relativePath() + "" + file.extension() + "")
.collect(java.util.stream.Collectors.joining(""));
String prompt = """
现在只执行材料检验。企业:%s申报等级%s。
已上传材料:%s
请递归读取 inputs/ 下材料,先调用相应文档 Skill再调用企业画像、材料诊断与知识库 Skill输出简洁的事实与缺口摘要。
若结构化读取无法覆盖扫描页、流程图或关键版面,可按需调用 document_view不要默认渲染全部页面。
材料存在时不得虚构材料事实;知识库内容只能补充背景、政策和规划依据,不能冒充企业事实。
完成首轮目录清点后,先创建满足下述结构的 JSON 初稿,再在读取过程中持续更新,避免把必需产物留到最后。
结束前必须把检验结果写入 work/facts/material-check.json使用 UTF-8 严格 JSON不能包含 Markdown。
JSON 必须包含 summary、completeness0-100 整数、confirmedFacts字符串数组和 missingItems对象数组
每个 missingItems 对象必须包含 id、label、reason、required。即使仅有企业名称也要给出可继续规划的最小缺口清单。
暂时不要生成建设规划或 DOCX。
完成后仅输出材料覆盖、已确认事实和待确认缺口,不说明内部文件、格式、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), fileSummary);
streamAgent(project, run, recoveryPrompt(prompt, resuming));
ObjectNode report = readMaterialCheckWithRepair(project, run);
ObjectNode ask = objectMapper.createObjectNode();
ask.put("kind", "material_check");
ask.put("interruptId", "materials-" + run.id());
ask.put("title", "确认材料检验");
ask.put("description", "确认缺口处理方式后生成建设规划");
ask.set("report", report);
finishWaiting(project.id(), run.id(), ask);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
/**
* 读取材料检验结果Agent 正常结束但未写出有效文件时,把校验错误反馈给同一线程继续修复。
*
* @param project 当前项目
* @param run 当前 Run
* @return 有效材料检验结果
* @throws IOException 修复后产物仍无法读取时抛出
*/
private ObjectNode readMaterialCheckWithRepair(ProjectService.ProjectView project, RunView run)
throws IOException {
int repairRound = 0;
while (true) {
try {
return outputService.readMaterialCheck(project.id());
} catch (ApiException | IOException exception) {
runStore.ensureRunning(run.id());
repairRound++;
log.warn("Agent 未生成有效材料检验结果继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
streamAgent(project, run, """
材料检验结构化产物校验失败:%s
请使用简体中文,优先复用当前上下文及 work/extracted 中已有结果,停止大范围补充探索。
立即修复并写入 work/facts/material-check.json使用 UTF-8 严格 JSON再调用 read_file 复核。
必须包含 summary、completeness、confirmedFacts、missingItems每个 missingItems 对象必须包含
id、label、reason、required。完成有效文件后再结束本轮。
""".formatted(exception.getMessage()));
}
}
}
/**
* 根据材料确认结果自动生成建设规划并进入规划 Ask。
*
* @param project 企业项目
* @param run 当前 Run
* @param userId 当前用户 ID
* @param materialResponse 材料确认结果
*/
private void executePlanningRun(
ProjectService.ProjectView project,
RunView run,
UUID userId,
JsonNode materialResponse,
boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "PLANNING"));
String prompt = """
现在生成建设规划。企业:%s申报等级%s。
材料检验确认结果:%s
请调用知识库、差距分析和建设规划 Skill自主形成与企业名称、行业线索和申报等级一致的方案。
已有企业材料中的事实不得改写或补造;缺少事实时允许使用知识库形成明确标记的规划假设。
结束前必须把建议规划写入 work/plans/proposed-plan.json使用 UTF-8 严格 JSON不能包含 Markdown。
JSON 必须包含 coreDirection、collaborationDirection、factoryName、planningYears1-10 的整数、investmentRange、
applicationLevel、scenarios字符串数组、aiScenarioCount整数和 assumptions字符串数组
暂时不要编写最终 DOCX。
完成后仅输出用户确认规划所需的方向、周期、投资、场景和假设,不说明内部文件、格式、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), materialResponse.toString());
streamAgent(project, run, recoveryPrompt(prompt, resuming));
ObjectNode plan = readProposedPlanWithRepair(project, run);
ProjectService.PlanView saved = projectService.saveDraftPlan(project.id(), plan, userId);
ObjectNode ask = objectMapper.createObjectNode();
ask.put("kind", "planning");
ask.put("interruptId", "plan-" + saved.id());
ask.put("title", "确认建设规划");
ask.put("description", "确认后 Agent 将自主完成申报书编写与评审");
ask.set("plan", plan);
ask.put("planId", saved.id().toString());
finishWaiting(project.id(), run.id(), ask);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
/**
* 读取建议规划;结构无效时把原因反馈给同一线程继续修复。
*
* @param project 当前项目
* @param run 当前 Run
* @return 有效建设规划
* @throws IOException 文件无法读取时抛出
*/
private ObjectNode readProposedPlanWithRepair(ProjectService.ProjectView project, RunView run)
throws IOException {
int repairRound = 0;
while (true) {
try {
return outputService.readProposedPlan(project.id());
} catch (ApiException | IOException exception) {
runStore.ensureRunning(run.id());
repairRound++;
log.warn("Agent 未生成有效建设规划继续同一线程修复runId={}round={}",
run.id(), repairRound, exception);
streamAgent(project, run, """
建设规划结构化产物校验失败:%s
请使用简体中文,复用当前上下文,立即修复 work/plans/proposed-plan.json 并调用 read_file 复核。
必须包含 coreDirection、collaborationDirection、factoryName、planningYears1-10 整数)、
investmentRange、applicationLevel、scenarios非空数组、aiScenarioCount 和 assumptions。
完成有效文件后再结束本轮。
""".formatted(exception.getMessage()));
}
}
}
private void executeWritingRun(
ProjectService.ProjectView project,
ProjectService.PlanView plan,
RunView run,
boolean resuming) {
try {
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
"threadId", project.threadId(), "runId", run.id(), "phase", "WRITING"));
String prompt = """
建设规划已确认。企业:%s申报等级%s。
确认规划 JSON%s
请自主完成完整申报书:调用分章编写、总结、评审和 docx Skill按需调用百炼知识库。
你拥有当前项目工作区的读、写和 shell 能力。最终文件必须保存到 artifacts/,扩展名为 .docx。
任何有企业材料可核对的内容必须据实;缺少企业事实的内容允许结合知识库形成合理方案,且在 Word 原生批注中标明待确认。
正文目标为 1 万至 2 万汉字。关键现状未知可以待确认;建设场景、技术路线、实施阶段、建议 KPI 和保障机制等未来规划必须结合材料、知识库和 Skill 充分展开,不能以待确认占位代替可合理形成的方案。
最终事实审计必须逐句检查:未知企业现状使用“需确认是否……”或“待企业提供……”等非断言句式,严禁先写肯定事实再附待确认批注;企业承诺必须写为待签署或待提供。
每个 Word 原生批注 ID 只能锚定一处commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一问题在多处引用时必须复制批注正文并分配新的唯一 ID。
即使 artifacts/ 已有旧稿,也必须为本轮重新生成并覆盖 DOCX再执行结构、一致性和事实边界校验。
请保持章节口径一致,完成评审后再交付。
最终回复只说明申报书完成情况和用户需要关注的确认事项,不汇报内部文件、工具、命令、落盘或校验过程。
""".formatted(project.companyName(), project.applicationLevel(), plan.plan().toString());
streamAgent(project, run, recoveryPrompt(prompt, resuming));
JsonNode metadata = objectMapper.valueToTree(Map.of(
"planVersion", plan.version(),
"companyName", project.companyName(),
"review", "DOCX 已通过结构与一致性校验"));
publishAndComplete(project.id(), run, metadata);
} catch (Exception exception) {
failUnlessInterrupted(run, exception);
}
}
private void streamAgent(ProjectService.ProjectView project, RunView run, String prompt) {
RunControl control = activeRuns.get(run.id());
executionService.execute(
project,
run,
"""
用户可见正文和执行说明默认使用简体中文;代码、命令、文件路径、标准原文和专有名词可保留原语言。
""" + prompt,
control == null ? reactor.core.publisher.Mono.never() : control.stopSignal.asMono(),
() -> runStore.ensureRunning(run.id()),
() -> runStore.isInterrupted(run.id()));
}
/**
* 将运行切换为等待输入。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param interrupt Ask 内容
*/
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
transactions.executeWithoutResult(status -> {
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'WAITING_INPUT', pending_interrupt = CAST(:interrupt AS jsonb),
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("interrupt", interrupt.toString())
.param("id", runId)
.update();
requireTerminalUpdate(updated);
eventService.append(projectId, runId, "RUN_FINISHED", Map.of("outcome", "INTERRUPT"));
});
}
/**
* 在当前事务成功提交后启动可中断的后台任务。
*
* @param runId Run ID
* @param task 后台任务
*/
private void afterCommit(UUID runId, Runnable task) {
RunControl control = new RunControl();
activeRuns.put(runId, control);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
executor.submit(() -> {
try {
task.run();
} finally {
activeRuns.remove(runId, control);
}
});
}
@Override
public void afterCompletion(int status) {
if (status != TransactionSynchronization.STATUS_COMMITTED) {
activeRuns.remove(runId, control);
}
}
});
}
/**
* 在当前事务成功提交后执行短操作。
*
* @param action 提交后操作
*/
private void onCommit(Runnable action) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
/**
* 为用户停止后的恢复 Run 增加上下文接续约束。
*
* @param prompt 当前阶段提示词
* @param resuming 是否恢复执行
* @return 最终提示词
*/
private String recoveryPrompt(String prompt, boolean resuming) {
if (!resuming) {
return prompt;
}
return """
这是用户停止后的继续运行。恢复同一 threadId 的会话状态,并先读取 MEMORY.md、work、references、artifacts。
复用已完成成果,从中断位置继续;不要重复已经完成的工具调用,先校验可能未完整写入的中间文件。
""" + prompt;
}
/**
* 已中断的 Run 不再写入失败终态。
*
* @param run 当前 Run
* @param exception 执行异常
*/
private void failUnlessInterrupted(RunView run, Exception exception) {
if (exception instanceof AgentExecutionService.RunInterruptedException || runStore.isInterrupted(run.id())) {
log.info("Agent Run 已停止runId={}", run.id());
return;
}
fail(run, exception);
}
/**
* 在一个事务中发布产物并标记运行正常完成。
*
* @param projectId 项目 ID
* @param run 当前 Run
* @param metadata 产物元数据
*/
private void publishAndComplete(UUID projectId, RunView run, JsonNode metadata) {
transactions.executeWithoutResult(status -> {
ArtifactService.ArtifactView artifact = artifactService.publishCandidate(
projectId, run.id(), run.startedAt().toInstant(), metadata);
eventService.append(projectId, run.id(), "ARTIFACT_PUBLISHED", artifact);
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL,
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("id", run.id())
.update();
requireTerminalUpdate(updated);
projectService.updateStatus(projectId, "DELIVERED");
eventService.append(projectId, run.id(), "RUN_FINISHED", Map.of("outcome", "SUCCESS"));
});
}
/**
* 原子标记运行失败并写入终止事件,已终止的 Run 不重复写事件。
*
* @param run 当前 Run
* @param exception 执行异常
*/
private void fail(RunView run, Exception exception) {
log.error("Agent Run 执行失败runId={}", run.id(), exception);
String message = exception instanceof ApiException && exception.getMessage() != null
? exception.getMessage()
: "Agent 执行失败,请稍后重试";
try {
transactions.executeWithoutResult(status -> {
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'FAILED', pending_interrupt = NULL, error_code = 'AGENT_RUN_FAILED',
error_message = :message, ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'RUNNING'
""")
.param("message", message)
.param("id", run.id())
.update();
if (updated == 1) {
eventService.append(run.projectId(), run.id(), "RUN_ERROR", Map.of(
"code", "AGENT_RUN_FAILED", "message", message));
projectService.updateStatus(run.projectId(), "FAILED");
}
});
} catch (RuntimeException eventException) {
exception.addSuppressed(eventException);
log.error("Agent 失败事件持久化失败runId={}", run.id(), eventException);
}
}
/**
* 校验终态更新只命中当前运行中的 Run。
*
* @param updated 更新行数
*/
private void requireTerminalUpdate(int updated) {
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_FINISHED", "当前任务已经结束");
}
}
/**
* Agent Run 视图。
*
* @param id Run ID
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param status 运行状态
* @param pendingInterrupt 待处理 Ask JSON
* @param errorMessage 失败信息
* @param startedAt 开始时间
* @param endedAt 结束时间
*/
public record RunView(
UUID id,
UUID projectId,
String triggerType,
String status,
String pendingInterrupt,
String errorMessage,
OffsetDateTime startedAt,
OffsetDateTime endedAt) {
}
/**
* 规划确认与编写启动的原子操作结果。
*
* @param plan 已确认规划
* @param run 编写 Run
*/
public record ConfirmPlanResult(ProjectService.PlanView plan, RunView run) {
}
/**
* 保存活跃任务的流取消信号。
*/
private static final class RunControl {
private final Sinks.One<Void> stopSignal = Sinks.one();
/**
* 取消 Agent 流订阅,停止继续输出和后续工具调用。
*/
private void cancel() {
stopSignal.tryEmitEmpty();
}
}
}

View File

@@ -0,0 +1,233 @@
package cn.alphaline.smartfactory.agent;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
/**
* 集中读写 Agent Run 持久化状态。
*/
@Service
public class AgentRunStore {
private final JdbcClient jdbc;
private final ObjectMapper objectMapper;
/**
* 创建 Run 状态存储。
*
* @param jdbc JDBC 客户端
* @param objectMapper JSON 映射器
*/
public AgentRunStore(JdbcClient jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
/**
* 创建无并发冲突的新 Run。
*
* @param projectId 项目 ID
* @param triggerType 触发类型
* @param parentRunId 父 Run ID
* @return 新 Run
*/
public AgentRunService.RunView create(UUID projectId, String triggerType, UUID parentRunId) {
Integer active = jdbc.sql("""
SELECT count(*) FROM app.agent_run
WHERE project_id = :projectId AND status IN ('RUNNING', 'WAITING_INPUT')
""")
.param("projectId", projectId)
.query(Integer.class)
.single();
if (active > 0) {
throw new ApiException(HttpStatus.CONFLICT, "RUN_ALREADY_ACTIVE", "项目已有正在执行或等待确认的任务");
}
UUID id = UUID.randomUUID();
UUID modelId = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.single();
jdbc.sql("""
INSERT INTO app.agent_run(
id, project_id, parent_run_id, model_config_id, trigger_type, status, trace_id)
VALUES (:id, :projectId, :parentRunId, :modelId, :triggerType, 'RUNNING', :traceId)
""")
.param("id", id)
.param("projectId", projectId)
.param("parentRunId", parentRunId)
.param("modelId", modelId)
.param("triggerType", triggerType)
.param("traceId", UUID.randomUUID().toString())
.update();
return require(id);
}
/**
* 返回项目最近 Run。
*
* @param projectId 项目 ID
* @return 最近 Run不存在时为空
*/
public AgentRunService.RunView latest(UUID projectId) {
return jdbc.sql(RUN_SELECT + " WHERE project_id = :projectId ORDER BY created_at DESC LIMIT 1")
.param("projectId", projectId)
.query(AgentRunStore::mapRun)
.optional()
.orElse(null);
}
/**
* 获取指定 Run。
*
* @param id Run ID
* @return Run
*/
public AgentRunService.RunView require(UUID id) {
return jdbc.sql(RUN_SELECT + " WHERE id = :id")
.param("id", id)
.query(AgentRunStore::mapRun)
.single();
}
/**
* 完成等待输入的 Run。
*
* @param runId Run ID
*/
public void completeWaiting(UUID runId) {
int updated = jdbc.sql("""
UPDATE app.agent_run
SET status = 'COMPLETED', pending_interrupt = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND status = 'WAITING_INPUT'
""")
.param("id", runId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_ALREADY_RESPONDED", "该确认已处理,请刷新页面");
}
}
/**
* 获取当前等待中的指定 Ask。
*
* @param projectId 项目 ID
* @param kind Ask 类型
* @return 等待中的 Run
*/
public AgentRunService.RunView requireWaiting(UUID projectId, String kind) {
AgentRunService.RunView run = latest(projectId);
if (run == null || !"WAITING_INPUT".equals(run.status()) || run.pendingInterrupt() == null) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_NOT_WAITING", "当前没有等待确认的内容");
}
try {
if (!kind.equals(objectMapper.readTree(run.pendingInterrupt()).path("kind").asText())) {
throw new ApiException(HttpStatus.CONFLICT, "ASK_TYPE_MISMATCH", "确认内容与当前阶段不一致");
}
return run;
} catch (JsonProcessingException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ASK_STATE_INVALID", "确认状态无法读取");
}
}
/**
* 确保 Run 仍处于运行状态。
*
* @param runId Run ID
*/
public void ensureRunning(UUID runId) {
String status = jdbc.sql("SELECT status FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(String.class)
.single();
if (!"RUNNING".equals(status)) {
throw new AgentExecutionService.RunInterruptedException();
}
}
/**
* 判断 Run 是否已由用户停止。
*
* @param runId Run ID
* @return 是否已停止
*/
public boolean isInterrupted(UUID runId) {
return jdbc.sql("SELECT status = 'INTERRUPTED' FROM app.agent_run WHERE id = :id")
.param("id", runId)
.query(Boolean.class)
.optional()
.orElse(false);
}
/**
* 从持久化事件读取中断前阶段。
*
* @param run 已中断 Run
* @param project 当前项目
* @return 业务阶段
*/
public String interruptedPhase(AgentRunService.RunView run, ProjectService.ProjectView project) {
return jdbc.sql("""
SELECT payload ->> 'phase' FROM app.agent_event
WHERE run_id = :runId AND event_type = 'RUN_STARTED'
ORDER BY id DESC LIMIT 1
""")
.param("runId", run.id())
.query(String.class)
.optional()
.orElse(project.status());
}
/**
* 读取规划恢复所需的最近材料确认结果。
*
* @param projectId 项目 ID
* @return 材料确认 JSON
*/
public JsonNode latestMaterialResponse(UUID projectId) {
return jdbc.sql("""
SELECT payload::text FROM app.agent_event
WHERE project_id = :projectId AND event_type = 'ASK_RESPONDED'
AND jsonb_typeof(payload -> 'decisions') = 'array'
ORDER BY id DESC LIMIT 1
""")
.param("projectId", projectId)
.query(String.class)
.optional()
.map(value -> {
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MATERIAL_RESPONSE_INVALID",
"材料确认记录无法读取");
}
})
.orElseGet(objectMapper::createObjectNode);
}
private static AgentRunService.RunView mapRun(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new AgentRunService.RunView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("trigger_type"),
rs.getString("status"),
rs.getString("pending_interrupt"),
rs.getString("error_message"),
rs.getObject("started_at", OffsetDateTime.class),
rs.getObject("ended_at", OffsetDateTime.class));
}
private static final String RUN_SELECT = """
SELECT id, project_id, trigger_type, status, pending_interrupt, error_message, started_at, ended_at
FROM app.agent_run
""";
}

View File

@@ -0,0 +1,169 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.harness.agent.HarnessAgent;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse;
import io.agentscope.harness.agent.filesystem.model.WriteResult;
import io.agentscope.harness.agent.filesystem.sandbox.SandboxBackedFilesystem;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 在当前 Harness 沙箱内把指定文档视图渲染为多模态图片。
*/
public final class DocumentViewTool {
private static final int TOOL_TIMEOUT_SECONDS = 180;
private final ObjectMapper objectMapper;
private volatile HarnessAgent harness;
/**
* 创建文档视觉工具。
*
* @param objectMapper JSON 映射器
*/
public DocumentViewTool(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 绑定拥有沙箱工作区的 Harness Agent。
*
* @param harness 当前工具所属的 Harness Agent
*/
void bind(HarnessAgent harness) {
this.harness = harness;
}
/**
* 渲染一个或多个文档页面、幻灯片、工作表范围或图片,并把图片返回给多模态模型。
*
* @param views 查看请求;建议每次不超过 5 个,允许按任务需要分批调用
* @param runtimeContext 当前运行上下文
* @return 包含结构化结果和成功图片的工具观察
*/
@Tool(
name = "document_view",
description = "按需查看 PDF/DOCX 页、PPTX 幻灯片、XLS/XLSX 工作表范围或图片。"
+ "先使用文档 Skill 做结构化读取,只有内容缺失、扫描件或视觉布局重要时再调用。"
+ "views 支持多个请求,建议每次最多 5 个;单个失败不影响其他结果。",
readOnly = true)
public ToolResultBlock view(
@ToolParam(
name = "views",
description = "查看请求数组。path 为工作区相对路径PDF/DOCX/PPTX 可给 page"
+ "XLS/XLSX 可给 sheet 和 range可选 dpi 72-220、format 为 png/jpeg。")
List<ViewRequest> views,
RuntimeContext runtimeContext) {
if (views == null || views.isEmpty()) {
return ToolResultBlock.text("document_view 执行失败views 至少包含一个查看请求");
}
HarnessAgent activeHarness = harness;
if (activeHarness == null) {
return ToolResultBlock.text("document_view 执行失败:当前 Agent 没有 Harness 工作区");
}
AbstractFilesystem filesystem = activeHarness.getWorkspaceManager().getFilesystem();
if (!(filesystem instanceof SandboxBackedFilesystem sandbox)) {
return ToolResultBlock.text("document_view 执行失败:当前工作区不支持文档渲染");
}
String directory = "work/tmp/document-view/" + UUID.randomUUID();
String requestPath = directory + "/request.json";
String resultPath = directory + "/result.json";
try {
WriteResult write = filesystem.write(
runtimeContext,
requestPath,
objectMapper.writeValueAsString(Map.of("views", views)));
if (!write.isSuccess()) {
return ToolResultBlock.text("document_view 执行失败:" + write.error());
}
ExecuteResponse execution = sandbox.execute(
runtimeContext,
"python /opt/agent-runtime/document_view.py " + requestPath + " " + resultPath,
TOOL_TIMEOUT_SECONDS);
FileDownloadResponse downloaded = filesystem.downloadFiles(runtimeContext, List.of(resultPath)).getFirst();
if (!downloaded.isSuccess()) {
return ToolResultBlock.text("document_view 执行失败:"
+ safeError(execution.output(), downloaded.error()));
}
JsonNode result = objectMapper.readTree(new String(downloaded.content(), StandardCharsets.UTF_8));
List<ContentBlock> output = new ArrayList<>();
output.add(TextBlock.builder()
.text("document_view_result=" + objectMapper.writeValueAsString(result))
.build());
List<Map<String, Object>> metadata = new ArrayList<>();
for (JsonNode image : result.path("images")) {
String path = image.path("path").asText();
FileDownloadResponse imageFile = filesystem.downloadFiles(runtimeContext, List.of(path)).getFirst();
if (!imageFile.isSuccess()) {
continue;
}
output.add(ImageBlock.builder()
.source(Base64Source.builder()
.mediaType(image.path("mediaType").asText("image/png"))
.data(Base64.getEncoder().encodeToString(imageFile.content()))
.build())
.build());
metadata.add(objectMapper.convertValue(image, new TypeReference<LinkedHashMap<String, Object>>() { }));
}
return ToolResultBlock.of(output, Map.of("images", metadata));
} catch (Exception exception) {
return ToolResultBlock.text("document_view 执行失败:" + safeError(exception.getMessage(), null));
}
}
/**
* 选择并限制返回给 Agent 的诊断信息。
*
* @param primary 首选错误信息
* @param fallback 备用错误信息
* @return 有界错误文本
*/
private String safeError(String primary, String fallback) {
String value = primary == null || primary.isBlank() ? fallback : primary;
if (value == null || value.isBlank()) {
return "未返回可诊断信息,请缩小查看范围后重试";
}
value = value.strip();
return value.length() > 600 ? value.substring(value.length() - 600) : value;
}
/**
* 单个文档查看参数。
*
* @param path 工作区相对路径
* @param page 一基页码或幻灯片编号
* @param sheet 工作表名称
* @param range Excel A1 范围
* @param dpi 渲染 DPI
* @param format png 或 jpeg
*/
public record ViewRequest(
@ToolParam(name = "path", description = "工作区相对路径") String path,
@ToolParam(name = "page", required = false, description = "一基页码或幻灯片编号") Integer page,
@ToolParam(name = "sheet", required = false, description = "Excel 工作表名称") String sheet,
@ToolParam(name = "range", required = false, description = "Excel A1 范围,例如 A1:H30") String range,
@ToolParam(name = "dpi", required = false, description = "渲染 DPI建议 120-180") Integer dpi,
@ToolParam(name = "format", required = false, description = "png 或 jpeg") String format) {
}
}

View File

@@ -0,0 +1,191 @@
package cn.alphaline.smartfactory.agent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.harness.agent.filesystem.AbstractFilesystem;
import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import io.agentscope.harness.agent.filesystem.model.ReadResult;
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 提供带明确续读位置的文件读取工具,避免沙箱输出上限造成静默截断。
*/
final class PagedReadFileTool {
static final int DEFAULT_PAGE_LINES = 400;
private static final int SAFE_PAGE_BYTES = 300 * 1024;
private final AbstractFilesystem filesystem;
private final WorkspacePathNormalizer pathNormalizer;
private final ObjectMapper objectMapper;
/**
* 创建分页读取工具。
*
* @param filesystem AgentScope 文件系统
* @param pathNormalizer 工作区路径归一化器
* @param objectMapper JSON 映射器
*/
PagedReadFileTool(
AbstractFilesystem filesystem,
WorkspacePathNormalizer pathNormalizer,
ObjectMapper objectMapper) {
this.filesystem = filesystem;
this.pathNormalizer = pathNormalizer;
this.objectMapper = objectMapper;
}
/**
* 按行读取文件;内容未读完时返回下一页 offset。
*
* @param runtimeContext Agent 运行上下文
* @param path 文件路径
* @param offset 起始行,基于零
* @param limit 本页最多行数
* @return 文件内容及必要的续读提示
*/
@Tool(
name = "read_file",
readOnly = true,
description =
"Read UTF-8 file content by lines. When content remains, the result explicitly"
+ " provides nextOffset for the next call.")
public String readFile(
RuntimeContext runtimeContext,
@ToolParam(name = "path", description = "File path to read") String path,
@ToolParam(
name = "offset",
description = "Start line (0-indexed). Default: 0",
required = false)
Integer offset,
@ToolParam(
name = "limit",
description = "Max lines to return. Default: 400",
required = false)
Integer limit) {
int start = offset == null ? 0 : offset;
int pageLines = limit == null || limit <= 0 ? DEFAULT_PAGE_LINES : limit;
if (start < 0) {
return "Error: offset 不能小于 0";
}
String normalizedPath = pathNormalizer.normalize(path);
if (filesystem instanceof AbstractSandboxFilesystem sandbox) {
return readFromSandbox(sandbox, runtimeContext, normalizedPath, start, pageLines);
}
return readFromFilesystem(runtimeContext, normalizedPath, start, pageLines);
}
private String readFromSandbox(
AbstractSandboxFilesystem sandbox,
RuntimeContext runtimeContext,
String path,
int offset,
int limit) {
String encodedPath = Base64.getEncoder().encodeToString(path.getBytes(StandardCharsets.UTF_8));
String command = """
python3 - <<'PY'
import base64, json
path = base64.b64decode('%s').decode('utf-8')
offset = %d
limit = %d
cap = %d
try:
with open(path, 'rb') as source:
text = source.read().decode('utf-8')
lines = text.splitlines()
start = min(offset, len(lines))
selected = []
size = 0
line_too_long = False
for line in lines[start:start + limit]:
data = (line + '\\n').encode('utf-8')
if size + len(data) > cap:
line_too_long = not selected
break
selected.append(line)
size += len(data)
next_offset = start + len(selected)
meta = {
'ok': True,
'truncated': next_offset < len(lines),
'nextOffset': next_offset,
'returnedLines': len(selected),
'lineTooLong': line_too_long
}
print(json.dumps(meta, ensure_ascii=False, separators=(',', ':')))
print(base64.b64encode('\\n'.join(selected).encode('utf-8')).decode('ascii'))
except FileNotFoundError:
print(json.dumps({'ok': False, 'error': 'file_not_found'}, separators=(',', ':')))
except UnicodeDecodeError:
print(json.dumps({'ok': False, 'error': 'not_utf8_text'}, separators=(',', ':')))
except Exception as error:
print(json.dumps({'ok': False, 'error': str(error)}, ensure_ascii=False, separators=(',', ':')))
PY
""".formatted(encodedPath, offset, limit, SAFE_PAGE_BYTES);
ExecuteResponse response = sandbox.execute(runtimeContext, command, null);
String output = response.output() == null ? "" : response.output();
int split = output.indexOf('\n');
String header = split < 0 ? output.strip() : output.substring(0, split).strip();
try {
JsonNode meta = objectMapper.readTree(header);
if (!meta.path("ok").asBoolean()) {
return switch (meta.path("error").asText()) {
case "file_not_found" -> "Error: 文件不存在:" + path;
case "not_utf8_text" -> "Error: 文件不是 UTF-8 文本,请调用相应文档 Skill 或 document_view";
default -> "Error: 读取文件失败:" + meta.path("error").asText("未知错误");
};
}
int nextOffset = meta.path("nextOffset").asInt(offset);
if (response.truncated()) {
return incompleteNotice(path, nextOffset, limit);
}
String encoded = split < 0 ? "" : output.substring(split + 1).strip();
String content = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);
if (meta.path("lineTooLong").asBoolean()) {
return "Error: 当前行超过安全读取范围,请使用 execute_shell_command 分段读取该行;内容未被静默截断";
}
return meta.path("truncated").asBoolean()
? content + "\n\n" + incompleteNotice(path, nextOffset, limit)
: content;
} catch (Exception exception) {
return "Error: 无法解析文件读取结果,内容可能未读完,请缩小 limit 后重试";
}
}
private String readFromFilesystem(
RuntimeContext runtimeContext, String path, int offset, int limit) {
ReadResult result = filesystem.read(runtimeContext, path, offset, limit + 1);
if (!result.isSuccess()) {
return "Error: " + result.error();
}
if (result.fileData() == null) {
return "";
}
if (!"utf-8".equalsIgnoreCase(result.fileData().encoding())) {
return result.fileData().content();
}
String[] lines = result.fileData().content().split("\\R", -1);
if (lines.length <= limit) {
return result.fileData().content();
}
return String.join("\n", java.util.Arrays.copyOf(lines, limit))
+ "\n\n"
+ incompleteNotice(path, offset + limit, limit);
}
private String incompleteNotice(String path, int nextOffset, int limit) {
return "[系统提示:内容未读完。请继续调用 read_file(path=\""
+ path
+ "\", offset="
+ nextOffset
+ ", limit="
+ limit
+ ")。]";
}
}

View File

@@ -0,0 +1,44 @@
package cn.alphaline.smartfactory.agent;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 启动时终结因 JVM 中断而遗留的伪运行状态,并保留原业务阶段供继续执行。
*/
@Component
@Order(0)
public class RunRecoveryService implements ApplicationRunner {
private final JdbcClient jdbc;
/**
* 创建恢复服务。
*
* @param jdbc JDBC 客户端
*/
public RunRecoveryService(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/**
* 将仍为 RUNNING 的旧 Run 标记为已中断。
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
jdbc.sql("""
UPDATE app.agent_run
SET status = 'INTERRUPTED', pending_interrupt = NULL,
error_code = 'PROCESS_RESTARTED', error_message = '服务重启,运行已中断',
ended_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'RUNNING'
""").update();
}
}

View File

@@ -0,0 +1,59 @@
package cn.alphaline.smartfactory.artifact;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 提供项目产物列表与下载接口。
*/
@RestController
@RequestMapping("/api")
public class ArtifactController {
private final ArtifactService artifactService;
/**
* 创建产物控制器。
*
* @param artifactService 产物服务
*/
public ArtifactController(ArtifactService artifactService) {
this.artifactService = artifactService;
}
/**
* 列出项目产物。
*
* @param projectId 项目 ID
* @return 产物列表
*/
@GetMapping("/projects/{projectId}/artifacts")
public List<ArtifactService.ArtifactView> list(@PathVariable UUID projectId) {
return artifactService.list(projectId);
}
/**
* 下载产物。
*
* @param artifactId 产物 ID
* @return 文件响应
*/
@GetMapping("/artifacts/{artifactId}/download")
public ResponseEntity<org.springframework.core.io.Resource> download(@PathVariable UUID artifactId) {
ArtifactService.Download download = artifactService.download(artifactId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(download.mimeType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
.body(download.resource());
}
}

View File

@@ -0,0 +1,307 @@
package cn.alphaline.smartfactory.artifact;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.project.ProjectFileService;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
/**
* 校验、登记和下载 Agent 最终产物。
*/
@Service
public class ArtifactService {
private final JdbcClient jdbc;
private final ProjectFileService fileService;
private final DocxValidator docxValidator;
/**
* 创建产物服务。
*
* @param jdbc JDBC 客户端
* @param fileService 项目文件服务
* @param docxValidator DOCX 校验器
*/
public ArtifactService(JdbcClient jdbc, ProjectFileService fileService, DocxValidator docxValidator) {
this.jdbc = jdbc;
this.fileService = fileService;
this.docxValidator = docxValidator;
}
/**
* 校验本轮沙箱候选 DOCX原子复制到正式目录并登记产物。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param runStartedAt Run 开始时间
* @param metadata 业务元数据
* @return 已发布产物
*/
public ArtifactView publishCandidate(
UUID projectId,
UUID runId,
Instant runStartedAt,
JsonNode metadata) {
Path candidates = fileService.safeProjectPath(projectId, "work/candidates");
Path candidate = newestDocx(candidates, runStartedAt);
DocxValidator.ValidationResult validation = docxValidator.validate(candidate);
Path target = fileService.safeProjectPath(projectId, "artifacts/" + candidate.getFileName());
Path temporary = target.resolveSibling(target.getFileName() + ".publishing");
try {
Files.copy(candidate, temporary, StandardCopyOption.REPLACE_EXISTING);
docxValidator.validate(temporary);
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException exception) {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupException) {
exception.addSuppressed(cleanupException);
}
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_COPY_FAILED", "申报书发布失败");
}
com.fasterxml.jackson.databind.node.ObjectNode enriched = metadata.isObject()
? ((com.fasterxml.jackson.databind.node.ObjectNode) metadata).deepCopy()
: com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode();
enriched.put("docxValidated", true);
enriched.put("docxEntries", validation.entryCount());
enriched.put("commentCount", validation.commentCount());
return publish(
projectId,
runId,
"DOCX",
target.getFileName().toString(),
"artifacts/" + target.getFileName(),
enriched);
}
/**
* 发布工作区中的 DOCX 产物。
*
* @param projectId 项目 ID
* @param runId Run ID
* @param kind 产物类型
* @param name 文件名
* @param relativePath 项目相对路径
* @param metadata 业务摘要
* @return 产物元数据
*/
public ArtifactView publish(
UUID projectId,
UUID runId,
String kind,
String name,
String relativePath,
JsonNode metadata) {
Path path = fileService.safeProjectPath(projectId, relativePath);
if (!relativePath.startsWith("artifacts/") || !Files.isRegularFile(path)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_INVALID", "产物文件不存在或不在发布目录");
}
try {
long size = Files.size(path);
if (size <= 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "ARTIFACT_EMPTY", "产物文件为空");
}
String hash = sha256(path);
UUID id = jdbc.sql("""
INSERT INTO app.artifact(
id, project_id, run_id, kind, name, relative_path, mime_type,
size_bytes, sha256, metadata_json)
VALUES (:id, :projectId, :runId, :kind, :name, :path,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
:size, :sha256, CAST(:metadata AS jsonb))
ON CONFLICT (project_id, relative_path) DO UPDATE SET
run_id = EXCLUDED.run_id,
kind = EXCLUDED.kind,
name = EXCLUDED.name,
mime_type = EXCLUDED.mime_type,
size_bytes = EXCLUDED.size_bytes,
sha256 = EXCLUDED.sha256,
metadata_json = EXCLUDED.metadata_json,
published_at = CURRENT_TIMESTAMP
RETURNING id
""")
.param("id", UUID.randomUUID())
.param("projectId", projectId)
.param("runId", runId)
.param("kind", kind)
.param("name", name)
.param("path", relativePath)
.param("size", size)
.param("sha256", hash)
.param("metadata", metadata.toString())
.query(UUID.class)
.single();
return require(id);
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PUBLISH_FAILED", "产物校验失败");
}
}
private Path newestDocx(Path directory, Instant runStartedAt) {
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
return paths
.filter(path -> path.getFileName().toString().toLowerCase(java.util.Locale.ROOT).endsWith(".docx"))
.filter(path -> path.toFile().lastModified() >= runStartedAt.toEpochMilli())
.max(java.util.Comparator.comparingLong(path -> path.toFile().lastModified()))
.orElseThrow(() -> new ApiException(
HttpStatus.UNPROCESSABLE_ENTITY,
"DOCX_NOT_GENERATED",
"Agent 未生成本轮 DOCX"));
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_SCAN_FAILED", "申报书候选目录无法读取");
}
}
/**
* 列出项目产物。
*
* @param projectId 项目 ID
* @return 按发布时间倒序的产物
*/
public List<ArtifactView> list(UUID projectId) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE project_id = :projectId ORDER BY published_at DESC")
.param("projectId", projectId)
.query(ArtifactService::mapArtifact)
.list();
}
/**
* 获取产物下载资源。
*
* @param artifactId 产物 ID
* @return 下载信息
*/
public Download download(UUID artifactId) {
StoredArtifact artifact = jdbc.sql("""
SELECT project_id, name, relative_path, mime_type, size_bytes, sha256
FROM app.artifact WHERE id = :id
""")
.param("id", artifactId)
.query((rs, rowNum) -> new StoredArtifact(
rs.getObject("project_id", UUID.class),
rs.getString("name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getLong("size_bytes"),
rs.getString("sha256")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
try {
Path path = fileService.safeProjectPath(artifact.projectId(), artifact.relativePath());
Resource resource = new UrlResource(path.toUri());
if (!resource.exists()) {
throw new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_FILE_NOT_FOUND", "产物文件不存在");
}
if (Files.size(path) != artifact.sizeBytes() || !sha256(path).equals(artifact.sha256())) {
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
}
return new Download(artifact.name(), artifact.mimeType(), resource);
} catch (java.net.MalformedURLException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "ARTIFACT_PATH_INVALID", "产物路径无效");
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.CONFLICT, "ARTIFACT_INTEGRITY_FAILED", "产物完整性校验失败,请重新生成");
}
}
/**
* 计算文件 SHA-256。
*
* @param path 文件路径
* @return 小写十六进制哈希
* @throws IOException 文件读取失败时抛出
* @throws NoSuchAlgorithmException 运行环境不支持 SHA-256 时抛出
*/
private String sha256(Path path) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream input = Files.newInputStream(path)) {
byte[] buffer = new byte[8192];
for (int read; (read = input.read(buffer)) >= 0;) {
digest.update(buffer, 0, read);
}
}
return HexFormat.of().formatHex(digest.digest());
}
private ArtifactView require(UUID id) {
return jdbc.sql(ARTIFACT_SELECT + " WHERE id = :id")
.param("id", id)
.query(ArtifactService::mapArtifact)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ARTIFACT_NOT_FOUND", "产物不存在"));
}
private static ArtifactView mapArtifact(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ArtifactView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getObject("run_id", UUID.class),
rs.getString("kind"),
rs.getString("name"),
rs.getLong("size_bytes"),
rs.getString("metadata_json"),
rs.getObject("published_at", OffsetDateTime.class));
}
private static final String ARTIFACT_SELECT = """
SELECT id, project_id, run_id, kind, name, size_bytes, metadata_json, published_at
FROM app.artifact
""";
/**
* 产物元数据。
*
* @param id 产物 ID
* @param projectId 项目 ID
* @param runId 生成 Run
* @param kind 类型
* @param name 文件名
* @param sizeBytes 文件大小
* @param metadataJson 业务摘要 JSON
* @param publishedAt 发布时间
*/
public record ArtifactView(
UUID id,
UUID projectId,
UUID runId,
String kind,
String name,
long sizeBytes,
String metadataJson,
OffsetDateTime publishedAt) {
}
/**
* 下载结果。
*
* @param name 下载文件名
* @param mimeType MIME 类型
* @param resource 文件资源
*/
public record Download(String name, String mimeType, Resource resource) {
}
private record StoredArtifact(
UUID projectId,
String name,
String relativePath,
String mimeType,
long sizeBytes,
String sha256) {
}
}

View File

@@ -0,0 +1,194 @@
package cn.alphaline.smartfactory.artifact;
import cn.alphaline.smartfactory.common.ApiException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* 使用 JDK 标准库校验 DOCX 容器、核心 OOXML 和原生批注引用。
*/
@Component
public class DocxValidator {
private static final Set<String> REQUIRED_ENTRIES = Set.of(
"[Content_Types].xml", "_rels/.rels", "word/document.xml");
private static final int MAX_ENTRIES = 10_000;
private static final long MAX_UNCOMPRESSED_BYTES = 512L * 1024 * 1024;
/**
* 校验 DOCX 文件可以被 Word 作为完整 OOXML 文档读取。
*
* @param path DOCX 文件
* @return 校验摘要
* @throws ApiException 文件损坏、结构缺失或批注引用异常时抛出
*/
public ValidationResult validate(Path path) {
if (!Files.isRegularFile(path)) {
throw invalid("DOCX 文件不存在");
}
try (ZipFile zip = new ZipFile(path.toFile())) {
Set<String> names = new HashSet<>();
long uncompressed = 0;
var entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String name = entry.getName();
if (name.startsWith("/") || name.contains("../") || !names.add(name)) {
throw invalid("DOCX 包含非法或重复路径");
}
if (names.size() > MAX_ENTRIES) {
throw invalid("DOCX 文件数量超过限制");
}
if (entry.getSize() > 0) {
uncompressed = Math.addExact(uncompressed, entry.getSize());
if (uncompressed > MAX_UNCOMPRESSED_BYTES) {
throw invalid("DOCX 解压后大小超过限制");
}
}
}
if (!names.containsAll(REQUIRED_ENTRIES)) {
throw invalid("DOCX 缺少必要的 Word 文档结构");
}
Document document = parse(zip, "word/document.xml");
if (!"document".equals(document.getDocumentElement().getLocalName())
|| document.getElementsByTagNameNS("*", "body").getLength() != 1
|| document.getElementsByTagNameNS("*", "t").getLength() == 0) {
throw invalid("DOCX 正文结构为空或无效");
}
int comments = validateComments(zip, names, document);
return new ValidationResult(Files.size(path), names.size(), comments);
} catch (ApiException exception) {
throw exception;
} catch (ArithmeticException | IOException exception) {
throw invalid("DOCX 容器损坏或无法读取");
}
}
private int validateComments(ZipFile zip, Set<String> names, Document document) throws IOException {
Map<String, Integer> references = idCounts(document, "commentReference");
Map<String, Integer> starts = idCounts(document, "commentRangeStart");
Map<String, Integer> ends = idCounts(document, "commentRangeEnd");
Set<String> referenced = new HashSet<>(references.keySet());
referenced.addAll(starts.keySet());
referenced.addAll(ends.keySet());
if (!names.contains("word/comments.xml")) {
if (!referenced.isEmpty()) {
throw invalid("DOCX 正文引用了不存在的批注");
}
return 0;
}
Document comments = parse(zip, "word/comments.xml");
Set<String> declared = new HashSet<>();
NodeList nodes = comments.getElementsByTagNameNS("*", "comment");
for (int index = 0; index < nodes.getLength(); index++) {
String id = attributeByLocalName((Element) nodes.item(index), "id");
if (id.isBlank() || !declared.add(id)) {
throw invalid("DOCX 包含重复或无编号批注");
}
}
if (declared.isEmpty() || !declared.equals(referenced)) {
throw invalid("DOCX 批注与正文锚点不一致");
}
for (String id : declared) {
if (references.getOrDefault(id, 0) != 1
|| starts.getOrDefault(id, 0) != 1
|| ends.getOrDefault(id, 0) != 1) {
throw invalid("DOCX 批注锚点重复或不完整");
}
}
if (!names.contains("word/_rels/document.xml.rels")) {
throw invalid("DOCX 批注缺少关系定义");
}
Document relationships = parse(zip, "word/_rels/document.xml.rels");
boolean linked = false;
NodeList relations = relationships.getElementsByTagNameNS("*", "Relationship");
for (int index = 0; index < relations.getLength(); index++) {
Element relation = (Element) relations.item(index);
if ("comments.xml".equals(relation.getAttribute("Target"))) {
linked = true;
break;
}
}
if (!linked) {
throw invalid("DOCX 批注关系未连接到正文");
}
return declared.size();
}
private Map<String, Integer> idCounts(Document document, String localName) {
Map<String, Integer> values = new HashMap<>();
NodeList nodes = document.getElementsByTagNameNS("*", localName);
for (int index = 0; index < nodes.getLength(); index++) {
String id = attributeByLocalName((Element) nodes.item(index), "id");
if (!id.isBlank()) {
values.merge(id, 1, Integer::sum);
}
}
return values;
}
private String attributeByLocalName(Element element, String localName) {
for (int index = 0; index < element.getAttributes().getLength(); index++) {
Node attribute = element.getAttributes().item(index);
if (localName.equals(attribute.getLocalName()) || localName.equals(attribute.getNodeName())) {
return attribute.getNodeValue();
}
}
return "";
}
private Document parse(ZipFile zip, String name) throws IOException {
ZipEntry entry = zip.getEntry(name);
if (entry == null) {
throw invalid("DOCX 缺少必要 XML" + name);
}
try (InputStream input = zip.getInputStream(entry)) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return factory.newDocumentBuilder().parse(input);
} catch (ApiException exception) {
throw exception;
} catch (Exception exception) {
throw invalid("DOCX XML 无法解析:" + name);
}
}
private ApiException invalid(String message) {
return new ApiException(HttpStatus.UNPROCESSABLE_ENTITY, "DOCX_INVALID", message);
}
/**
* DOCX 校验摘要。
*
* @param sizeBytes 文件大小
* @param entryCount ZIP 条目数
* @param commentCount 原生批注数
*/
public record ValidationResult(long sizeBytes, int entryCount, int commentCount) {
}
}

View File

@@ -0,0 +1,104 @@
package cn.alphaline.smartfactory.auth;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.security.Principal;
import java.util.Map;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 管理登录状态和 CSRF Token。
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final HttpSessionSecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository();
/**
* 创建认证控制器。
*
* @param authenticationManager 认证管理器
*/
public AuthController(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
/**
* 返回并初始化 CSRF Token。
*
* @param token 当前请求 Token
* @return Token 数据
*/
@GetMapping("/csrf")
public Map<String, String> csrf(CsrfToken token) {
return Map.of("token", token.getToken(), "headerName", token.getHeaderName());
}
/**
* 使用用户名和密码创建 Session。
*
* @param request 登录请求
* @param servletRequest HTTP 请求
* @param servletResponse HTTP 响应
* @return 当前用户摘要
*/
@PostMapping("/login")
public MeResponse login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
contextRepository.saveContext(context, servletRequest, servletResponse);
return new MeResponse(authentication.getName(), "管理员");
}
/**
* 返回当前登录账户。
*
* @param principal 当前身份
* @return 当前用户摘要
*/
@GetMapping("/me")
public MeResponse me(Principal principal) {
return new MeResponse(principal.getName(), "管理员");
}
/**
* 登录请求。
*
* @param username 登录名
* @param password 密码
*/
public record LoginRequest(@NotBlank String username, @NotBlank String password) {
}
/**
* 当前用户摘要。
*
* @param username 登录名
* @param displayName 显示名称
*/
public record MeResponse(String username, String displayName) {
}
}

View File

@@ -0,0 +1,97 @@
package cn.alphaline.smartfactory.auth;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.util.UUID;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.core.annotation.Order;
/**
* 管理单管理员账户和当前用户标识。
*/
@Service
@Order(1)
public class UserService implements UserDetailsService, ApplicationRunner {
private final JdbcClient jdbc;
private final PasswordEncoder passwordEncoder;
private final AppProperties properties;
/**
* 创建用户服务。
*
* @param jdbc JDBC 客户端
* @param passwordEncoder 密码编码器
* @param properties 应用配置
*/
public UserService(JdbcClient jdbc, PasswordEncoder passwordEncoder, AppProperties properties) {
this.jdbc = jdbc;
this.passwordEncoder = passwordEncoder;
this.properties = properties;
}
/**
* 初始化本地管理员账户。
*
* @param args 启动参数
*/
@Override
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.app_user").query(Integer.class).single();
if (count == 0) {
jdbc.sql("""
INSERT INTO app.app_user(id, username, password_hash, display_name)
VALUES (:id, :username, :password, :displayName)
""")
.param("id", UUID.randomUUID())
.param("username", properties.adminUsername())
.param("password", passwordEncoder.encode(properties.adminPassword()))
.param("displayName", "管理员")
.update();
}
}
/**
* 加载 Spring Security 用户。
*
* @param username 登录名
* @return 用户详情
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return jdbc.sql("SELECT username, password_hash, enabled FROM app.app_user WHERE username = :username")
.param("username", username)
.query((rs, rowNum) -> User.withUsername(rs.getString("username"))
.password(rs.getString("password_hash"))
.roles("ADMIN")
.disabled(!rs.getBoolean("enabled"))
.build())
.optional()
.orElseThrow(() -> new UsernameNotFoundException("账户不存在"));
}
/**
* 获取指定登录名的用户 ID。
*
* @param username 登录名
* @return 用户 UUID
* @throws ApiException 用户不存在时抛出
*/
public UUID requireUserId(String username) {
return jdbc.sql("SELECT id FROM app.app_user WHERE username = :username")
.param("username", username)
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在"));
}
}

View File

@@ -0,0 +1,15 @@
package cn.alphaline.smartfactory.common;
import java.time.Instant;
/**
* 统一接口错误响应。
*
* @param code 稳定错误码
* @param message 可理解错误信息
* @param traceId 日志关联标识
* @param timestamp 发生时间
*/
public record ApiError(String code, String message, String traceId, Instant timestamp) {
}

View File

@@ -0,0 +1,44 @@
package cn.alphaline.smartfactory.common;
import org.springframework.http.HttpStatus;
/**
* 可安全返回给调用方的业务异常。
*/
public class ApiException extends RuntimeException {
private final HttpStatus status;
private final String code;
/**
* 创建业务异常。
*
* @param status HTTP 状态
* @param code 稳定错误码
* @param message 可操作错误说明
*/
public ApiException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
/**
* 返回 HTTP 状态。
*
* @return HTTP 状态
*/
public HttpStatus status() {
return status;
}
/**
* 返回稳定错误码。
*
* @return 错误码
*/
public String code() {
return code;
}
}

View File

@@ -0,0 +1,135 @@
package cn.alphaline.smartfactory.common;
import jakarta.validation.ConstraintViolationException;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
/**
* 将业务错误和未预期异常转换为明确的 HTTP 错误响应。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* 处理已知业务异常。
*
* @param exception 业务异常
* @return 对应状态码的错误响应
*/
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiError> handleApiException(ApiException exception) {
return ResponseEntity.status(exception.status())
.body(error(exception.code(), exception.getMessage()));
}
/**
* 处理输入校验失败。
*
* @param exception 参数校验异常
* @return 400 错误响应
*/
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
public ResponseEntity<ApiError> handleValidation(Exception exception) {
String message = exception instanceof MethodArgumentNotValidException invalid
? invalid.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getField() + "" + error.getDefaultMessage())
.orElse("请求参数无效")
: exception.getMessage();
return ResponseEntity.badRequest().body(error("VALIDATION_FAILED", message));
}
/**
* 将错误凭据统一映射为 401。
*
* @param exception 认证异常
* @return 401 错误响应
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiError> handleAuthentication(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(error("AUTHENTICATION_FAILED", "用户名或密码错误"));
}
/**
* 将权限或 CSRF 拒绝统一映射为 403。
*
* @param exception 权限异常
* @return 403 错误响应
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException exception) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(error("ACCESS_DENIED", "当前请求无权执行"));
}
/**
* 处理无法解析的请求体。
*
* @param exception JSON 读取异常
* @return 400 错误响应
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleUnreadable(HttpMessageNotReadableException exception) {
return ResponseEntity.badRequest().body(error("REQUEST_BODY_INVALID", "请求内容格式无效"));
}
/**
* 处理数据库唯一性等并发冲突。
*
* @param exception 数据约束异常
* @return 409 错误响应
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiError> handleConflict(DataIntegrityViolationException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(error("DATA_CONFLICT", "数据已发生变化,请刷新后重试"));
}
/**
* 收敛流式响应中客户端主动断开产生的预期异常。
*
* @param exception 已提交响应无法继续写入的异常
*/
@ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleClientDisconnect(AsyncRequestNotUsableException exception) {
log.debug("客户端已断开流式响应:{}", exception.getMessage());
}
/**
* 处理未预期异常并保留完整堆栈。
*
* @param exception 未预期异常
* @return 500 错误响应
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception exception) {
String traceId = traceId();
log.error("未预期异常traceId={}", traceId, exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("INTERNAL_ERROR", "服务处理失败,请稍后重试", traceId, java.time.Instant.now()));
}
private ApiError error(String code, String message) {
return new ApiError(code, message, traceId(), java.time.Instant.now());
}
private String traceId() {
String value = MDC.get("traceId");
return value == null || value.isBlank() ? UUID.randomUUID().toString() : value;
}
}

View File

@@ -0,0 +1,45 @@
package cn.alphaline.smartfactory.common;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* 为每个 HTTP 请求建立可回传、可检索的追踪编号。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
/**
* 在请求处理期间写入 MDC并在响应中返回追踪编号。
*
* @param request HTTP 请求
* @param response HTTP 响应
* @param filterChain 后续过滤器链
* @throws ServletException Servlet 处理失败
* @throws IOException 网络读写失败
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String traceId = UUID.randomUUID().toString();
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove("traceId");
}
}
}

View File

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

View File

@@ -0,0 +1,36 @@
package cn.alphaline.smartfactory.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
/**
* 进程内基础设施配置。
*/
@Configuration
public class InfraConfig {
/**
* 创建适合阻塞模型及文件调用的虚拟线程执行器。
*
* @return 应用共享执行器
*/
@Bean(destroyMethod = "close")
public ExecutorService applicationExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
* 复用虚拟线程处理 Spring MVC 的异步响应。
*
* @param applicationExecutor 应用共享执行器
* @return MVC 异步执行器
*/
@Bean
public AsyncTaskExecutor applicationTaskExecutor(ExecutorService applicationExecutor) {
return new TaskExecutorAdapter(applicationExecutor);
}
}

View File

@@ -0,0 +1,96 @@
package cn.alphaline.smartfactory.config;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiError;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.UUID;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
/**
* 单管理员 Cookie Session 安全配置。
*/
@Configuration
public class SecurityConfig {
/**
* 创建密码编码器。
*
* @return BCrypt 编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 创建认证管理器。
*
* @param configuration Spring Security 认证配置
* @return 认证管理器
* @throws Exception 配置解析失败时抛出
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
/**
* 定义接口授权和 CSRF 规则。
*
* @param http HTTP 安全构建器
* @param userService 用户加载服务
* @param objectMapper JSON 映射器
* @return 安全过滤链
* @throws Exception 安全规则构建失败时抛出
*/
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
UserService userService,
ObjectMapper objectMapper) throws Exception {
CookieCsrfTokenRepository csrf = CookieCsrfTokenRepository.withHttpOnlyFalse();
csrf.setCookiePath("/");
return http
.userDetailsService(userService)
.csrf(configurer -> configurer
.csrfTokenRepository(csrf)
.ignoringRequestMatchers("/api/auth/login"))
.authorizeHttpRequests(registry -> registry
.requestMatchers("/api/auth/login", "/api/auth/csrf", "/", "/index.html", "/assets/**")
.permitAll()
.anyRequest().authenticated())
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, exception) -> writeError(
response, objectMapper, 401, "AUTHENTICATION_REQUIRED", "请先登录"))
.accessDeniedHandler((request, response, exception) -> writeError(
response, objectMapper, 403, "ACCESS_DENIED", "当前请求无权执行")))
.requestCache(cache -> cache.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.logout(logout -> logout.logoutUrl("/api/auth/logout").logoutSuccessHandler((request, response, authentication) -> response.setStatus(204)))
.build();
}
private void writeError(
jakarta.servlet.http.HttpServletResponse response,
ObjectMapper objectMapper,
int status,
String code,
String message) throws java.io.IOException {
response.setStatus(status);
response.setCharacterEncoding(java.nio.charset.StandardCharsets.UTF_8.name());
response.setContentType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
new ApiError(code, message, UUID.randomUUID().toString(), Instant.now()));
}
}

View File

@@ -0,0 +1,31 @@
package cn.alphaline.smartfactory.config;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import javax.sql.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* AgentScope PostgreSQL Skill Repository 配置。
*/
@Configuration
public class SkillRepositoryConfig {
/**
* 创建由 Flyway 管理表结构的 Skill 仓库。
*
* @param dataSource 数据源
* @return 可写 Skill 仓库
*/
@Bean(destroyMethod = "close")
public PostgresSkillRepository postgresSkillRepository(DataSource dataSource) {
return PostgresSkillRepository.builder(dataSource)
.schemaName("agentscope")
.skillsTableName("agentscope_skills")
.resourcesTableName("agentscope_skill_resources")
.createIfNotExist(false)
.writeable(true)
.build();
}
}

View File

@@ -0,0 +1,84 @@
package cn.alphaline.smartfactory.model;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 使用 AES-GCM 加密数据库中的模型密钥。
*/
@Component
public class KeyCipher {
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
/**
* 创建密钥加密器。
*
* @param properties 应用配置
*/
public KeyCipher(AppProperties properties) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(properties.masterKey().getBytes(StandardCharsets.UTF_8));
this.key = new SecretKeySpec(digest, "AES");
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("无法初始化密钥加密器", exception);
}
}
/**
* 加密明文密钥。
*
* @param plaintext 明文
* @return IV 与密文组合字节
*/
public byte[] encrypt(String plaintext) {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
return ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array();
} catch (GeneralSecurityException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_ENCRYPT_FAILED", "模型密钥加密失败");
}
}
/**
* 解密数据库密钥。
*
* @param encrypted IV 与密文组合字节
* @return 明文密钥
*/
public String decrypt(byte[] encrypted) {
if (encrypted == null || encrypted.length <= IV_LENGTH) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_MISSING", "模型密钥未配置");
}
byte[] iv = Arrays.copyOfRange(encrypted, 0, IV_LENGTH);
byte[] ciphertext = Arrays.copyOfRange(encrypted, IV_LENGTH, encrypted.length);
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
} catch (GeneralSecurityException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "KEY_DECRYPT_FAILED", "模型密钥解密失败");
}
}
}

View File

@@ -0,0 +1,96 @@
package cn.alphaline.smartfactory.model;
import jakarta.validation.Valid;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 提供模型配置与连接测试接口。
*/
@RestController
@RequestMapping("/api/models")
public class ModelController {
private final ModelService modelService;
/**
* 创建模型控制器。
*
* @param modelService 模型服务
*/
public ModelController(ModelService modelService) {
this.modelService = modelService;
}
/**
* 列出模型。
*
* @return 模型列表
*/
@GetMapping
public List<ModelService.ModelView> list() {
return modelService.list();
}
/**
* 新增模型。
*
* @param input 模型输入
* @param principal 当前用户
* @return 新模型
*/
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ModelService.ModelView create(@Valid @RequestBody ModelService.ModelInput input, Principal principal) {
return modelService.save(null, input, principal);
}
/**
* 更新模型。
*
* @param id 模型 ID
* @param input 模型输入
* @param principal 当前用户
* @return 更新后的模型
*/
@PutMapping("/{id}")
public ModelService.ModelView update(
@PathVariable UUID id,
@Valid @RequestBody ModelService.ModelInput input,
Principal principal) {
return modelService.save(id, input, principal);
}
/**
* 测试模型连接。
*
* @param id 模型 ID
* @return 测试结果
*/
@PostMapping("/{id}/test")
public ModelService.ConnectionResult test(@PathVariable UUID id) {
return modelService.test(id);
}
/**
* 设置默认模型。
*
* @param id 模型 ID
* @param principal 当前用户
*/
@PostMapping("/{id}/default")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void setDefault(@PathVariable UUID id, Principal principal) {
modelService.setDefault(id, principal);
}
}

View File

@@ -0,0 +1,452 @@
package cn.alphaline.smartfactory.model;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.Principal;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 管理 OpenAI 兼容模型配置、密钥和连接测试。
*/
@Service
@Order(2)
public class ModelService implements ApplicationRunner {
private final JdbcClient jdbc;
private final UserService userService;
private final KeyCipher keyCipher;
private final AppProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
/**
* 创建模型服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param keyCipher 密钥加密器
* @param properties 应用配置
* @param objectMapper JSON 映射器
*/
public ModelService(
JdbcClient jdbc,
UserService userService,
KeyCipher keyCipher,
AppProperties properties,
ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.keyCipher = keyCipher;
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
/**
* 从项目根目录 Key 文件初始化默认模型。
*
* @param args 启动参数
*/
@Override
@Transactional
public void run(ApplicationArguments args) {
Integer count = jdbc.sql("SELECT count(*) FROM app.model_config").query(Integer.class).single();
if (count > 0 || !Files.isRegularFile(properties.deepseekKeyFile())) {
return;
}
try {
String key = Files.readString(properties.deepseekKeyFile(), StandardCharsets.UTF_8).trim();
if (key.isBlank()) {
return;
}
UUID adminId = jdbc.sql("SELECT id FROM app.app_user ORDER BY created_at LIMIT 1")
.query(UUID.class)
.single();
UUID modelId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, is_default, created_by)
VALUES (:id, '默认编排模型', 'OPENAI_COMPATIBLE', :baseUrl, :modelId,
:ciphertext, :hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), TRUE, :userId)
""")
.param("id", modelId)
.param("baseUrl", properties.modelBaseUrl())
.param("modelId", properties.modelId())
.param("ciphertext", keyCipher.encrypt(key))
.param("hint", hint(key))
.param("config", "{\"timeoutSeconds\":120,\"reasoningEffort\":\"high\"}")
.param("capabilities", json(Map.of(
"toolCalling", true,
"reasoning", true,
"contextWindow", properties.modelContextWindow())))
.param("userId", adminId)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("INSERT INTO app.model_assignment(role, model_config_id, assigned_by) VALUES (:role, :id, :userId)")
.param("role", role)
.param("id", modelId)
.param("userId", adminId)
.update();
}
} catch (IOException exception) {
throw new IllegalStateException("无法读取默认模型 Key", exception);
}
}
/**
* 列出模型配置,永不返回明文密钥。
*
* @return 模型列表
*/
public List<ModelView> list() {
return jdbc.sql(MODEL_SELECT + " ORDER BY is_default DESC, updated_at DESC")
.query(ModelService::mapModel)
.list();
}
/**
* 保存新增或已有模型配置。
*
* @param id 可选模型 ID
* @param input 模型输入
* @param principal 当前用户
* @return 保存后的模型
*/
@Transactional
public ModelView save(UUID id, ModelInput input, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
contextWindow(input.capabilities());
if (id == null) {
if (input.apiKey() == null || input.apiKey().isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "MODEL_KEY_REQUIRED", "新增模型需要 API Key");
}
id = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.model_config(
id, name, provider, base_url, model_id, api_key_ciphertext, api_key_hint,
key_version, config_json, capabilities_json, created_by)
VALUES (:id, :name, 'OPENAI_COMPATIBLE', :baseUrl, :modelId, :ciphertext,
:hint, 1, CAST(:config AS jsonb), CAST(:capabilities AS jsonb), :userId)
""")
.param("id", id)
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("userId", userId)
.update();
} else {
int updated = input.apiKey() == null || input.apiKey().isBlank()
? jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update()
: jdbc.sql("""
UPDATE app.model_config
SET name = :name, base_url = :baseUrl, model_id = :modelId,
api_key_ciphertext = :ciphertext, api_key_hint = :hint, key_version = 1,
config_json = CAST(:config AS jsonb), capabilities_json = CAST(:capabilities AS jsonb),
updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("name", input.name().trim())
.param("baseUrl", normalizeBaseUrl(input.baseUrl()))
.param("modelId", input.modelId().trim())
.param("ciphertext", keyCipher.encrypt(input.apiKey().trim()))
.param("hint", hint(input.apiKey().trim()))
.param("config", json(input.config()))
.param("capabilities", json(input.capabilities()))
.param("id", id)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在");
}
}
return require(id);
}
/**
* 将模型设置为所有角色默认模型。
*
* @param id 模型 ID
* @param principal 当前用户
*/
@Transactional
public void setDefault(UUID id, Principal principal) {
require(id);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("UPDATE app.model_config SET is_default = FALSE WHERE is_default").update();
jdbc.sql("UPDATE app.model_config SET is_default = TRUE, updated_at = CURRENT_TIMESTAMP WHERE id = :id")
.param("id", id)
.update();
for (String role : List.of("ORCHESTRATION", "WRITING", "REVIEW")) {
jdbc.sql("""
INSERT INTO app.model_assignment(role, model_config_id, assigned_by)
VALUES (:role, :id, :userId)
ON CONFLICT (role) DO UPDATE
SET model_config_id = EXCLUDED.model_config_id,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
""")
.param("role", role)
.param("id", id)
.param("userId", userId)
.update();
}
}
/**
* 使用最小 Chat Completion 请求测试连接。
*
* @param id 模型 ID
* @return 测试结果
*/
public ConnectionResult test(UUID id) {
ModelSecret model = requireSecret(id);
String requestJson = json(Map.of(
"model", model.modelId(),
"messages", List.of(Map.of("role", "user", "content", "回复 OK")),
"max_tokens", 8,
"stream", false));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(model.baseUrl() + "/chat/completions"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + model.apiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
long started = System.nanoTime();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
long elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED",
"模型连接失败,服务返回 HTTP " + response.statusCode());
}
return new ConnectionResult(true, elapsed, "连接正常");
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_GATEWAY, "MODEL_CONNECTION_FAILED", "无法连接模型服务");
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "MODEL_CONNECTION_INTERRUPTED", "模型连接测试已中断");
}
}
/**
* 获取当前默认模型及明文 Key仅供模型调用。
*
* @return 默认模型机密配置
*/
public ModelSecret defaultModelSecret() {
UUID id = jdbc.sql("SELECT id FROM app.model_config WHERE is_default AND enabled")
.query(UUID.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.CONFLICT, "MODEL_NOT_CONFIGURED", "请先配置可用模型"));
return requireSecret(id);
}
private ModelView require(UUID id) {
return jdbc.sql(MODEL_SELECT + " WHERE id = :id")
.param("id", id)
.query(ModelService::mapModel)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在"));
}
private ModelSecret requireSecret(UUID id) {
return jdbc.sql("""
SELECT id, base_url, model_id, api_key_ciphertext, capabilities_json::text
FROM app.model_config WHERE id = :id AND enabled
""")
.param("id", id)
.query((rs, rowNum) -> new ModelSecret(
rs.getObject("id", UUID.class),
rs.getString("base_url"),
rs.getString("model_id"),
keyCipher.decrypt(rs.getBytes("api_key_ciphertext")),
contextWindow(parseCapabilities(rs.getString("capabilities_json")))))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "MODEL_NOT_FOUND", "模型配置不存在或已停用"));
}
/**
* 读取并校验模型上下文窗口。
*
* @param capabilities 模型能力配置
* @return 上下文 Token 上限
*/
private int contextWindow(Map<String, Object> capabilities) {
Object value = capabilities == null ? null : capabilities.get("contextWindow");
if (!(value instanceof Number number) || number.intValue() < 8_192) {
throw new ApiException(
HttpStatus.BAD_REQUEST,
"MODEL_CONTEXT_WINDOW_INVALID",
"上下文窗口不能小于 8192 Token");
}
return number.intValue();
}
/**
* 解析数据库中的模型能力配置。
*
* @param json 能力 JSON
* @return 能力键值
*/
@SuppressWarnings("unchecked")
private Map<String, Object> parseCapabilities(String json) {
try {
return objectMapper.readValue(json, Map.class);
} catch (IOException exception) {
throw new ApiException(
HttpStatus.INTERNAL_SERVER_ERROR,
"MODEL_CAPABILITIES_INVALID",
"模型能力配置无法读取");
}
}
private static ModelView mapModel(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ModelView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("provider"),
rs.getString("base_url"),
rs.getString("model_id"),
rs.getString("api_key_hint"),
rs.getString("config_json"),
rs.getString("capabilities_json"),
rs.getBoolean("enabled"),
rs.getBoolean("is_default"),
rs.getObject("updated_at", OffsetDateTime.class));
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value == null ? Map.of() : value);
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_MODEL_CONFIG", "模型配置无法序列化");
}
}
private String normalizeBaseUrl(String baseUrl) {
String value = baseUrl.trim();
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
return value;
}
private static String hint(String key) {
return "••••" + key.substring(Math.max(0, key.length() - 4));
}
private static final String MODEL_SELECT = """
SELECT id, name, provider, base_url, model_id, api_key_hint, config_json,
capabilities_json, enabled, is_default, updated_at
FROM app.model_config
""";
/**
* 模型编辑输入。
*
* @param name 配置名称
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 新密钥;空值表示保留
* @param config 高级配置
* @param capabilities 能力声明
*/
public record ModelInput(
@NotBlank String name,
@NotBlank String baseUrl,
@NotBlank String modelId,
String apiKey,
Map<String, Object> config,
Map<String, Object> capabilities) {
}
/**
* 对外模型视图。
*
* @param id 模型 ID
* @param name 配置名称
* @param provider 服务商
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKeyHint 密钥遮罩
* @param configJson 高级配置
* @param capabilitiesJson 能力配置
* @param enabled 是否启用
* @param defaultModel 是否默认
* @param updatedAt 更新时间
*/
public record ModelView(
UUID id,
String name,
String provider,
String baseUrl,
String modelId,
String apiKeyHint,
String configJson,
String capabilitiesJson,
boolean enabled,
boolean defaultModel,
OffsetDateTime updatedAt) {
}
/**
* 内部模型机密配置。
*
* @param id 模型 ID
* @param baseUrl API 地址
* @param modelId 模型标识
* @param apiKey 明文密钥
* @param contextWindow 上下文 Token 上限
*/
public record ModelSecret(UUID id, String baseUrl, String modelId, String apiKey, int contextWindow) {
}
/**
* 连接测试结果。
*
* @param success 是否成功
* @param latencyMs 往返耗时
* @param message 状态说明
*/
public record ConnectionResult(boolean success, long latencyMs, String message) {
}
}

View File

@@ -0,0 +1,213 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.agent.AgentRunService;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供项目、规划和企业材料接口。
*/
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final ProjectService projectService;
private final ProjectFileService fileService;
private final AgentRunService runService;
/**
* 创建项目控制器。
*
* @param projectService 项目服务
* @param fileService 文件服务
* @param runService Agent Run 服务
*/
public ProjectController(
ProjectService projectService,
ProjectFileService fileService,
AgentRunService runService) {
this.projectService = projectService;
this.fileService = fileService;
this.runService = runService;
}
/**
* 列出项目。
*
* @return 项目列表
*/
@GetMapping
public List<ProjectService.ProjectView> list() {
return projectService.list();
}
/**
* 创建项目。
*
* @param request 创建参数
* @param principal 当前用户
* @return 新项目
*/
@PostMapping
public ProjectService.ProjectView create(@Valid @RequestBody CreateProjectRequest request, Principal principal) {
ProjectService.ProjectView project = projectService.create(
request.companyName(), request.applicationLevel(), principal);
fileService.ensureWorkspace(project.id());
return project;
}
/**
* 读取项目详情。
*
* @param projectId 项目 ID
* @return 项目详情
*/
@GetMapping("/{projectId}")
public ProjectService.ProjectView get(@PathVariable UUID projectId) {
return projectService.require(projectId);
}
/**
* 真删除项目、业务记录和工作区文件。
*
* @param projectId 项目 ID
*/
@DeleteMapping("/{projectId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID projectId) {
projectService.delete(projectId);
fileService.deleteWorkspace(projectId);
}
/**
* 返回项目当前规划。
*
* @param projectId 项目 ID
* @return 当前规划;未生成时为空响应体
*/
@GetMapping("/{projectId}/plan")
public ResponseEntity<ProjectService.PlanView> currentPlan(@PathVariable UUID projectId) {
ProjectService.PlanView plan = projectService.currentPlan(projectId);
return plan == null ? ResponseEntity.noContent().build() : ResponseEntity.ok(plan);
}
/**
* 确认建设规划。
*
* @param projectId 项目 ID
* @param request 确认参数
* @param principal 当前用户
* @return 已确认规划与已启动的编写 Run
*/
@PostMapping("/{projectId}/plan/confirm")
public AgentRunService.ConfirmPlanResult confirmPlan(
@PathVariable UUID projectId,
@Valid @RequestBody ConfirmPlanRequest request,
Principal principal) {
return runService.confirmPlanAndStartWriting(projectId, request.planId(), request.plan(), principal);
}
/**
* 上传企业材料。
*
* @param projectId 项目 ID
* @param file 上传文件
* @param relativePath 文件夹内相对路径
* @param principal 当前用户
* @return 文件元数据
*/
@PostMapping(path = "/{projectId}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ProjectFileService.FileView upload(
@PathVariable UUID projectId,
@RequestParam MultipartFile file,
@RequestParam(required = false) String relativePath,
Principal principal) {
return fileService.upload(projectId, file, relativePath, principal);
}
/**
* 列出企业材料。
*
* @param projectId 项目 ID
* @return 文件列表
*/
@GetMapping("/{projectId}/files")
public List<ProjectFileService.FileView> files(@PathVariable UUID projectId) {
return fileService.list(projectId);
}
/**
* 下载企业材料。
*
* @param projectId 项目 ID
* @param fileId 文件 ID
* @return 文件响应
*/
@GetMapping("/{projectId}/files/{fileId}/download")
public ResponseEntity<org.springframework.core.io.Resource> download(
@PathVariable UUID projectId,
@PathVariable UUID fileId) {
ProjectFileService.Download download = fileService.download(projectId, fileId);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(download.mimeType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(download.name(), StandardCharsets.UTF_8))
.body(download.resource());
}
/**
* 返回 document_view 生成的项目内预览图。
*
* @param projectId 项目 ID
* @param path 工作区相对路径
* @return 图片响应
*/
@GetMapping("/{projectId}/view-images")
public ResponseEntity<org.springframework.core.io.Resource> preview(
@PathVariable UUID projectId,
@RequestParam String path) {
ProjectFileService.Preview preview = fileService.preview(projectId, path);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(preview.mimeType()))
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
.body(preview.resource());
}
/**
* 项目创建参数。
*
* @param companyName 企业名称
* @param applicationLevel 申报等级
*/
public record CreateProjectRequest(@NotBlank String companyName, String applicationLevel) {
}
/**
* 规划确认参数。
*
* @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划
*/
public record ConfirmPlanRequest(UUID planId, JsonNode plan) {
}
}

View File

@@ -0,0 +1,438 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import org.apache.tika.Tika;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
/**
* 保存、校验和读取项目企业材料。
*/
@Service
public class ProjectFileService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"pdf", "docx", "xls", "xlsx", "pptx", "csv", "txt", "md",
"png", "jpg", "jpeg", "webp", "vsdx", "dwg");
private final JdbcClient jdbc;
private final UserService userService;
private final ProjectService projectService;
private final Path dataRoot;
private final Tika tika = new Tika();
/**
* 创建材料服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param projectService 项目服务
* @param properties 应用配置
*/
public ProjectFileService(
JdbcClient jdbc,
UserService userService,
ProjectService projectService,
AppProperties properties) {
this.jdbc = jdbc;
this.userService = userService;
this.projectService = projectService;
this.dataRoot = properties.dataRoot().toAbsolutePath().normalize();
}
/**
* 初始化项目工作区目录。
*
* @param projectId 项目 ID
*/
public void ensureWorkspace(UUID projectId) {
Path root = projectRoot(projectId);
try {
for (String directory : List.of(
"inputs", "work/facts", "work/plans", "work/drafts", "work/reviews", "work/tmp",
"work/candidates", "references", "artifacts", "skills")) {
Files.createDirectories(root.resolve(directory));
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_CREATE_FAILED", "项目工作区创建失败");
}
}
/**
* 删除项目的整个受控工作区。
*
* @param projectId 项目 ID
* @throws ApiException 文件删除失败时抛出
*/
public void deleteWorkspace(UUID projectId) {
Path root = projectRoot(projectId);
if (!Files.exists(root)) {
return;
}
try (var paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "WORKSPACE_DELETE_FAILED", "项目文件删除失败");
}
}
/**
* 上传并校验企业材料。
*
* @param projectId 项目 ID
* @param file 上传文件
* @param relativePath 浏览器提供的文件夹内相对路径;单文件上传时可为空
* @param principal 当前用户
* @return 文件元数据
*/
@Transactional
public FileView upload(UUID projectId, MultipartFile file, String relativePath, Principal principal) {
projectService.require(projectId);
String originalName = safeName(file.getOriginalFilename());
String extension = extension(originalName);
if (!ALLOWED_EXTENSIONS.contains(extension)) {
throw new ApiException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_TYPE_NOT_ALLOWED", "暂不支持该文件类型");
}
ensureWorkspace(projectId);
UUID fileId = UUID.randomUUID();
String workspacePath = normalizeUploadPath(relativePath, originalName);
Path target = safeProjectPath(projectId, workspacePath);
Path temporary = target.resolveSibling(target.getFileName() + ".uploading-" + fileId);
boolean moved = false;
try {
Files.createDirectories(target.getParent());
if (Files.exists(target)) {
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
}
String mime;
try (InputStream input = file.getInputStream()) {
mime = tika.detect(input, originalName);
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (DigestInputStream input = new DigestInputStream(file.getInputStream(), digest)) {
Files.copy(input, temporary);
}
Files.move(temporary, target);
moved = true;
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.project_file(
id, project_id, original_name, stored_name, relative_path, mime_type,
extension, size_bytes, sha256, uploaded_by)
VALUES (:id, :projectId, :originalName, :storedName, :relativePath, :mimeType,
:extension, :sizeBytes, :sha256, :userId)
""")
.param("id", fileId)
.param("projectId", projectId)
.param("originalName", originalName)
.param("storedName", target.getFileName().toString())
.param("relativePath", workspacePath)
.param("mimeType", mime)
.param("extension", extension)
.param("sizeBytes", Files.size(target))
.param("sha256", HexFormat.of().formatHex(digest.digest()))
.param("userId", userId)
.update();
return require(fileId);
} catch (FileAlreadyExistsException exception) {
cleanupFailedUpload(exception, temporary);
throw new ApiException(HttpStatus.CONFLICT, "FILE_ALREADY_EXISTS", "文件夹中存在同名文件");
} catch (IOException | NoSuchAlgorithmException exception) {
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_STORE_FAILED", "文件保存失败");
} catch (RuntimeException exception) {
cleanupFailedUpload(exception, moved ? new Path[]{temporary, target} : new Path[]{temporary});
throw exception;
}
}
/**
* 清理未完成上传留下的临时文件或孤儿正式文件。
*
* @param failure 原始异常
* @param paths 待清理路径
*/
private void cleanupFailedUpload(Throwable failure, Path... paths) {
for (Path path : paths) {
try {
Files.deleteIfExists(path);
} catch (IOException cleanupException) {
failure.addSuppressed(cleanupException);
}
}
}
/**
* 列出项目有效材料。
*
* @param projectId 项目 ID
* @return 文件元数据列表
*/
public List<FileView> list(UUID projectId) {
projectService.require(projectId);
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file
WHERE project_id = :projectId AND deleted_at IS NULL
ORDER BY relative_path
""")
.param("projectId", projectId)
.query(ProjectFileService::mapFile)
.list();
}
/**
* 获取材料下载资源。
*
* @param projectId 项目 ID
* @param fileId 文件 ID
* @return 文件资源
*/
public Download download(UUID projectId, UUID fileId) {
StoredFile stored = jdbc.sql("""
SELECT original_name, relative_path, mime_type
FROM app.project_file
WHERE id = :fileId AND project_id = :projectId AND deleted_at IS NULL AND status = 'READY'
""")
.param("fileId", fileId)
.param("projectId", projectId)
.query((rs, rowNum) -> new StoredFile(
rs.getString("original_name"), rs.getString("relative_path"), rs.getString("mime_type")))
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
try {
Resource resource = new UrlResource(safeProjectPath(projectId, stored.relativePath()).toUri());
if (!resource.exists()) {
throw new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件内容不存在");
}
return new Download(stored.originalName(), stored.mimeType(), resource);
} catch (java.net.MalformedURLException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_PATH_INVALID", "文件路径无效");
}
}
/**
* 读取 document_view 生成的项目内预览图。
*
* @param projectId 项目 ID
* @param relativePath 工作区相对路径
* @return 预览图资源
* @throws ApiException 路径非法或图片不存在时抛出
*/
public Preview preview(UUID projectId, String relativePath) {
projectService.require(projectId);
String normalized = relativePath == null ? "" : relativePath.replace('\\', '/');
if (!normalized.startsWith("work/tmp/document-view/")
|| !(normalized.endsWith(".png") || normalized.endsWith(".jpg") || normalized.endsWith(".jpeg"))) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PREVIEW_PATH_INVALID", "预览图路径无效");
}
Path path = safeProjectPath(projectId, normalized);
if (!Files.isRegularFile(path)) {
throw new ApiException(HttpStatus.NOT_FOUND, "PREVIEW_NOT_FOUND", "预览图不存在");
}
try {
String mimeType = Files.probeContentType(path);
return new Preview(mimeType == null ? "image/png" : mimeType, new UrlResource(path.toUri()));
} catch (IOException exception) {
throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "PREVIEW_READ_FAILED", "预览图读取失败");
}
}
/**
* 返回规范化项目根目录。
*
* @param projectId 项目 ID
* @return 项目根目录
*/
public Path projectRoot(UUID projectId) {
return dataRoot.resolve("projects").resolve(projectId.toString()).normalize();
}
/**
* 在项目根目录内解析相对路径。
*
* @param projectId 项目 ID
* @param relativePath 相对路径
* @return 安全绝对路径
*/
public Path safeProjectPath(UUID projectId, String relativePath) {
Path root = projectRoot(projectId);
Path result = root.resolve(relativePath).normalize();
if (!result.startsWith(root)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
}
verifyExistingParent(root, result);
return result;
}
/**
* 拒绝通过现有符号链接把后续路径解析到项目目录之外。
*
* @param root 项目根目录
* @param result 待使用路径
*/
private void verifyExistingParent(Path root, Path result) {
try {
Path existing = result;
while (existing != null && !Files.exists(existing)) {
existing = existing.getParent();
}
if (existing != null && !existing.toRealPath().startsWith(root.toRealPath())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "PATH_OUTSIDE_PROJECT", "文件路径超出项目范围");
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件路径无效");
}
}
private FileView require(UUID fileId) {
return jdbc.sql("""
SELECT id, project_id, original_name, relative_path, mime_type, extension,
size_bytes, status, created_at
FROM app.project_file WHERE id = :id
""")
.param("id", fileId)
.query(ProjectFileService::mapFile)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "FILE_NOT_FOUND", "文件不存在"));
}
private static FileView mapFile(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new FileView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getString("original_name"),
rs.getString("relative_path"),
rs.getString("mime_type"),
rs.getString("extension"),
rs.getLong("size_bytes"),
rs.getString("status"),
rs.getObject("created_at", OffsetDateTime.class));
}
private String safeName(String originalName) {
if (originalName == null || originalName.isBlank()) {
return "未命名文件";
}
return Path.of(originalName).getFileName().toString().replaceAll("[\\r\\n]", "_");
}
/**
* 规范化上传路径并保留用户选择的文件夹层级。
*
* @param suppliedPath 浏览器提供的文件夹内路径
* @param originalName 原始文件名
* @return 相对于项目根目录的 inputs 路径
* @throws ApiException 路径越界、过长或文件名不一致时抛出
*/
String normalizeUploadPath(String suppliedPath, String originalName) {
String candidate = suppliedPath == null || suppliedPath.isBlank()
? originalName
: suppliedPath.replace('\\', '/');
if (candidate.startsWith("/") || candidate.indexOf('\0') >= 0) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
Path path;
try {
path = Path.of(candidate).normalize();
} catch (RuntimeException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
if (path.isAbsolute() || path.getNameCount() == 0 || path.startsWith("..")
|| !path.getFileName().toString().equals(originalName)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
for (Path segment : path) {
String value = segment.toString();
if (value.isBlank() || ".".equals(value) || "..".equals(value)
|| value.getBytes(StandardCharsets.UTF_8).length > 255) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_INVALID", "文件夹路径无效");
}
}
String result = "inputs/" + path.toString().replace('\\', '/');
if (result.length() > 1_000 || originalName.length() > 500) {
throw new ApiException(HttpStatus.BAD_REQUEST, "FILE_PATH_TOO_LONG", "文件夹路径过长");
}
return result;
}
private String extension(String name) {
int dot = name.lastIndexOf('.');
return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
/**
* 企业材料元数据。
*
* @param id 文件 ID
* @param projectId 项目 ID
* @param name 原始文件名
* @param relativePath 工作区相对路径
* @param mimeType 检测到的 MIME
* @param extension 扩展名
* @param sizeBytes 文件大小
* @param status 文件状态
* @param createdAt 上传时间
*/
public record FileView(
UUID id,
UUID projectId,
String name,
String relativePath,
String mimeType,
String extension,
long sizeBytes,
String status,
OffsetDateTime createdAt) {
}
/**
* 下载结果。
*
* @param name 下载文件名
* @param mimeType MIME 类型
* @param resource 文件资源
*/
public record Download(String name, String mimeType, Resource resource) {
}
/**
* 文档视觉预览结果。
*
* @param mimeType 图片 MIME 类型
* @param resource 图片资源
*/
public record Preview(String mimeType, Resource resource) {
}
private record StoredFile(String originalName, String relativePath, String mimeType) {
}
}

View File

@@ -0,0 +1,326 @@
package cn.alphaline.smartfactory.project;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 管理企业项目和不可变规划版本。
*/
@Service
public class ProjectService {
private final JdbcClient jdbc;
private final UserService userService;
private final ObjectMapper objectMapper;
/**
* 创建项目服务。
*
* @param jdbc JDBC 客户端
* @param userService 用户服务
* @param objectMapper JSON 映射器
*/
public ProjectService(JdbcClient jdbc, UserService userService, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.userService = userService;
this.objectMapper = objectMapper;
}
/**
* 创建企业申报项目。
*
* @param companyName 企业名称
* @param applicationLevel 申报等级
* @param principal 当前用户
* @return 新项目
*/
@Transactional
public ProjectView create(String companyName, String applicationLevel, Principal principal) {
String level = normalizeLevel(applicationLevel);
UUID id = UUID.randomUUID();
UUID userId = userService.requireUserId(principal.getName());
String threadId = "project-" + id;
jdbc.sql("""
INSERT INTO app.project(
id, company_name, project_name, agui_thread_id, application_level, created_by)
VALUES (:id, :companyName, :projectName, :threadId, :level, :userId)
""")
.param("id", id)
.param("companyName", companyName.trim())
.param("projectName", companyName.trim())
.param("threadId", threadId)
.param("level", level)
.param("userId", userId)
.update();
return require(id);
}
/**
* 列出最近项目。
*
* @return 按更新时间倒序的项目
*/
public List<ProjectView> list() {
return jdbc.sql(PROJECT_SELECT + " ORDER BY updated_at DESC")
.query(ProjectService::mapProject)
.list();
}
/**
* 读取一个项目。
*
* @param projectId 项目 ID
* @return 项目详情
* @throws ApiException 项目不存在时抛出
*/
public ProjectView require(UUID projectId) {
return jdbc.sql(PROJECT_SELECT + " WHERE id = :id")
.param("id", projectId)
.query(ProjectService::mapProject)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在"));
}
/**
* 真删除项目及其全部业务记录。
*
* @param projectId 项目 ID
* @throws ApiException 项目不存在或仍有 Agent 正在执行时抛出
*/
@Transactional
public void delete(UUID projectId) {
require(projectId);
boolean running = jdbc.sql("""
SELECT EXISTS(
SELECT 1 FROM app.agent_run WHERE project_id = :projectId AND status = 'RUNNING'
)
""")
.param("projectId", projectId)
.query(Boolean.class)
.single();
if (running) {
throw new ApiException(HttpStatus.CONFLICT, "PROJECT_RUN_ACTIVE", "请先停止正在执行的任务");
}
for (String table : List.of("agent_event", "artifact", "project_plan", "project_file", "agent_run")) {
jdbc.sql("DELETE FROM app." + table + " WHERE project_id = :projectId")
.param("projectId", projectId)
.update();
}
int deleted = jdbc.sql("DELETE FROM app.project WHERE id = :projectId")
.param("projectId", projectId)
.update();
if (deleted != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
}
/**
* 更新项目业务阶段。
*
* @param projectId 项目 ID
* @param status 新阶段
*/
public void updateStatus(UUID projectId, String status) {
int updated = jdbc.sql("""
UPDATE app.project
SET status = :status, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = :id
""")
.param("status", status)
.param("id", projectId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "PROJECT_NOT_FOUND", "项目不存在");
}
}
/**
* 保存 Agent 生成的规划草稿。
*
* @param projectId 项目 ID
* @param plan 规划 JSON
* @param userId 创建人
* @return 规划版本
*/
@Transactional
public PlanView saveDraftPlan(UUID projectId, JsonNode plan, UUID userId) {
Integer version = jdbc.sql("SELECT COALESCE(MAX(plan_version), 0) + 1 FROM app.project_plan WHERE project_id = :id")
.param("id", projectId)
.query(Integer.class)
.single();
UUID planId = UUID.randomUUID();
jdbc.sql("""
INSERT INTO app.project_plan(id, project_id, plan_version, status, plan_json, created_by)
VALUES (:id, :projectId, :version, 'DRAFT', CAST(:plan AS jsonb), :userId)
""")
.param("id", planId)
.param("projectId", projectId)
.param("version", version)
.param("plan", plan.toString())
.param("userId", userId)
.update();
updateStatus(projectId, "PLANNING");
return requirePlan(planId);
}
/**
* 返回项目当前规划。
*
* @param projectId 项目 ID
* @return 最新规划;不存在时返回空
*/
public PlanView currentPlan(UUID projectId) {
return jdbc.sql("""
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan
WHERE project_id = :projectId
ORDER BY CASE status WHEN 'CONFIRMED' THEN 0 ELSE 1 END, plan_version DESC
LIMIT 1
""")
.param("projectId", projectId)
.query(this::mapPlan)
.optional()
.orElse(null);
}
/**
* 确认规划并冻结其内容。
*
* @param projectId 项目 ID
* @param planId 草稿规划 ID
* @param plan 用户确认后的完整规划
* @param principal 当前用户
* @return 已确认规划
*/
@Transactional
public PlanView confirmPlan(UUID projectId, UUID planId, JsonNode plan, Principal principal) {
UUID userId = userService.requireUserId(principal.getName());
int updated = jdbc.sql("""
UPDATE app.project_plan
SET status = 'CONFIRMED', plan_json = CAST(:plan AS jsonb), confirmed_by = :userId,
confirmed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = :planId AND project_id = :projectId AND status = 'DRAFT'
""")
.param("plan", plan.toString())
.param("userId", userId)
.param("planId", planId)
.param("projectId", projectId)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.CONFLICT, "PLAN_ALREADY_CONFIRMED", "规划已确认或版本不存在");
}
updateStatus(projectId, "WRITING");
return requirePlan(planId);
}
private PlanView requirePlan(UUID planId) {
return jdbc.sql("""
SELECT id, project_id, plan_version, status, plan_json, confirmed_at, created_at
FROM app.project_plan WHERE id = :id
""")
.param("id", planId)
.query(this::mapPlan)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "PLAN_NOT_FOUND", "规划不存在"));
}
private PlanView mapPlan(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
try {
return new PlanView(
rs.getObject("id", UUID.class),
rs.getObject("project_id", UUID.class),
rs.getInt("plan_version"),
rs.getString("status"),
objectMapper.readTree(rs.getString("plan_json")),
rs.getObject("confirmed_at", OffsetDateTime.class),
rs.getObject("created_at", OffsetDateTime.class));
} catch (JsonProcessingException exception) {
throw new java.sql.SQLException("规划 JSON 无法解析", exception);
}
}
private static ProjectView mapProject(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new ProjectView(
rs.getObject("id", UUID.class),
rs.getString("company_name"),
rs.getString("project_name"),
rs.getString("agui_thread_id"),
rs.getString("application_level"),
rs.getString("status"),
rs.getLong("version"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class));
}
private String normalizeLevel(String level) {
String value = level == null ? "ADVANCED" : level.trim().toUpperCase(java.util.Locale.ROOT);
if (!value.equals("ADVANCED") && !value.equals("EXCELLENT")) {
throw new ApiException(HttpStatus.BAD_REQUEST, "INVALID_APPLICATION_LEVEL", "申报等级无效");
}
return value;
}
private static final String PROJECT_SELECT = """
SELECT id, company_name, project_name, agui_thread_id, application_level, status,
version, created_at, updated_at
FROM app.project
""";
/**
* 项目视图。
*
* @param id 项目 ID
* @param companyName 企业名称
* @param projectName 项目名称
* @param threadId AG-UI 线程 ID
* @param applicationLevel 申报等级
* @param status 当前状态
* @param version 乐观锁版本
* @param createdAt 创建时间
* @param updatedAt 更新时间
*/
public record ProjectView(
UUID id,
String companyName,
String projectName,
String threadId,
String applicationLevel,
String status,
long version,
OffsetDateTime createdAt,
OffsetDateTime updatedAt) {
}
/**
* 规划版本视图。
*
* @param id 规划 ID
* @param projectId 项目 ID
* @param version 版本号
* @param status 规划状态
* @param plan 规划 JSON
* @param confirmedAt 确认时间
* @param createdAt 创建时间
*/
public record PlanView(
UUID id,
UUID projectId,
int version,
String status,
JsonNode plan,
OffsetDateTime confirmedAt,
OffsetDateTime createdAt) {
}
}

View File

@@ -0,0 +1,113 @@
package cn.alphaline.smartfactory.skill;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供 Skill 导入、查看和启停接口。
*/
@RestController
@RequestMapping("/api/skills")
public class SkillController {
private final SkillService skillService;
/**
* 创建 Skill 控制器。
*
* @param skillService Skill 服务
*/
public SkillController(SkillService skillService) {
this.skillService = skillService;
}
/**
* 列出 Skill。
*
* @return Skill 列表
*/
@GetMapping
public List<SkillService.SkillView> list() {
return skillService.list();
}
/**
* 读取 Skill 详情。
*
* @param name Skill 名称
* @return Skill 详情
*/
@GetMapping("/{name}")
public SkillService.SkillDetail get(@PathVariable String name) {
return skillService.require(name);
}
/**
* 读取一个文本资源。
*
* @param name Skill 名称
* @param path 资源路径
* @return 资源正文
*/
@GetMapping("/{name}/resource")
public String resource(@PathVariable String name, @RequestParam String path) {
return skillService.resource(name, path);
}
/**
* 导入 Skill ZIP。
*
* @param file ZIP 文件
* @param principal 当前用户
* @return 导入后的 Skill
*/
@PostMapping(path = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public SkillService.SkillView importZip(@RequestParam MultipartFile file, Principal principal) {
return skillService.importZip(file, principal);
}
/**
* 启用 Skill。
*
* @param name Skill 名称
*/
@PostMapping("/{name}/enable")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void enable(@PathVariable String name) {
skillService.setEnabled(name, true);
}
/**
* 停用 Skill。
*
* @param name Skill 名称
*/
@PostMapping("/{name}/disable")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void disable(@PathVariable String name) {
skillService.setEnabled(name, false);
}
/**
* 删除用户导入 Skill。
*
* @param name Skill 名称
*/
@DeleteMapping("/{name}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String name) {
skillService.deleteImported(name);
}
}

View File

@@ -0,0 +1,149 @@
package cn.alphaline.smartfactory.skill;
import cn.alphaline.smartfactory.common.ApiException;
import io.agentscope.core.skill.AgentSkill;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 读取并校验标准 Skill 目录。
*/
@Component
public class SkillPackageReader {
private static final Pattern FRONTMATTER = Pattern.compile("\\A---\\s*\\R(.*?)\\R---\\s*\\R", Pattern.DOTALL);
private static final Pattern FIELD = Pattern.compile("(?m)^([A-Za-z][A-Za-z0-9_-]*):\\s*(.+?)\\s*$");
/**
* 读取一个 Skill 目录。
*
* @param directory Skill 根目录
* @param source 仓库来源
* @return 已校验 Skill 包
*/
public SkillPackage read(Path directory, String source) {
Path root = directory.toAbsolutePath().normalize();
Path skillFile = root.resolve("SKILL.md");
if (!Files.isRegularFile(skillFile)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FILE_MISSING", "Skill 缺少 SKILL.md");
}
try {
List<Path> files = Files.walk(root)
.filter(Files::isRegularFile)
.filter(path -> !isHidden(root.relativize(path)))
.sorted(Comparator.comparing(path -> normalize(root.relativize(path))))
.toList();
if (files.size() > 500) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_TOO_MANY_FILES", "Skill 文件数量超过限制");
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Map<String, String> resources = new HashMap<>();
String content = null;
for (Path file : files) {
String relative = normalize(root.relativize(file));
byte[] bytes = Files.readAllBytes(file);
String text = decodeUtf8(bytes, relative);
digest.update(relative.getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(bytes);
if (relative.equals("SKILL.md")) {
content = text;
} else {
resources.put(relative, text);
}
}
Map<String, String> frontmatter = frontmatter(content);
String name = required(frontmatter, "name");
String description = required(frontmatter, "description");
String version = frontmatter.getOrDefault("version", "v1.0");
AgentSkill skill = new AgentSkill(name, description, content, resources, source);
return new SkillPackage(skill, version, HexFormat.of().formatHex(digest.digest()), files.size());
} catch (IOException | NoSuchAlgorithmException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_READ_FAILED", "Skill 文件读取失败");
}
}
private Map<String, String> frontmatter(String content) {
Matcher block = FRONTMATTER.matcher(content == null ? "" : content);
if (!block.find()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_MISSING", "SKILL.md 缺少 YAML Frontmatter");
}
Map<String, String> values = new HashMap<>();
Matcher field = FIELD.matcher(block.group(1));
while (field.find()) {
values.put(field.group(1), unquote(field.group(2).trim()));
}
return values;
}
private String required(Map<String, String> values, String key) {
String value = values.get(key);
if (value == null || value.isBlank()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_FRONTMATTER_INVALID", "SKILL.md 缺少 " + key);
}
return value;
}
private String decodeUtf8(byte[] bytes, String path) {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_BINARY_RESOURCE", "Skill 资源必须是 UTF-8 文本:" + path);
}
}
private boolean isHidden(Path relative) {
for (Path part : relative) {
if (part.toString().startsWith(".")) {
return true;
}
}
return false;
}
private String normalize(Path path) {
return path.toString().replace('\\', '/');
}
private String unquote(String value) {
if (value.length() >= 2
&& ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'")))) {
return value.substring(1, value.length() - 1);
}
return value;
}
/**
* 已解析 Skill 包。
*
* @param skill AgentScope Skill
* @param version 展示版本
* @param checksum 目录校验和
* @param fileCount 文件数
*/
public record SkillPackage(AgentSkill skill, String version, String checksum, int fileCount) {
}
}

View File

@@ -0,0 +1,368 @@
package cn.alphaline.smartfactory.skill;
import cn.alphaline.smartfactory.auth.UserService;
import cn.alphaline.smartfactory.common.ApiException;
import cn.alphaline.smartfactory.config.AppProperties;
import io.agentscope.core.skill.AgentSkill;
import io.agentscope.core.skill.repository.postgresql.PostgresSkillRepository;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Principal;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
/**
* 提供 Skill 查看、导入和启停能力。
*/
@Service
public class SkillService {
private static final int MAX_ZIP_ENTRIES = 500;
private static final long MAX_UNCOMPRESSED_BYTES = 20L * 1024 * 1024;
private final JdbcClient jdbc;
private final PostgresSkillRepository repository;
private final SkillPackageReader packageReader;
private final UserService userService;
private final AppProperties properties;
/**
* 创建 Skill 服务。
*
* @param jdbc JDBC 客户端
* @param repository AgentScope PostgreSQL 仓库
* @param packageReader Skill 包读取器
* @param userService 用户服务
* @param properties 应用配置
*/
public SkillService(
JdbcClient jdbc,
PostgresSkillRepository repository,
SkillPackageReader packageReader,
UserService userService,
AppProperties properties) {
this.jdbc = jdbc;
this.repository = repository;
this.packageReader = packageReader;
this.userService = userService;
this.properties = properties;
}
/**
* 列出 Skill 配置。
*
* @return Skill 列表
*/
public List<SkillView> list() {
return jdbc.sql(SKILL_SELECT + " ORDER BY c.source_type, s.name")
.query(SkillService::mapSkill)
.list();
}
/**
* 读取 Skill 详情。
*
* @param name Skill 名称
* @return Skill 详情
*/
public SkillDetail require(String name) {
SkillView view = jdbc.sql(SKILL_SELECT + " WHERE s.name = :name")
.param("name", name)
.query(SkillService::mapSkill)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
AgentSkill skill = repository.getSkill(name);
if (skill == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 内容不存在");
}
return new SkillDetail(view, skill.getSkillContent(), skill.getResourcePaths().stream().sorted().toList());
}
/**
* 读取 Skill 文本资源。
*
* @param name Skill 名称
* @param path 资源相对路径
* @return 资源文本
*/
public String resource(String name, String path) {
if (path == null || path.isBlank() || path.startsWith("/") || path.contains("..")) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_RESOURCE_PATH_INVALID", "Skill 资源路径无效");
}
AgentSkill skill = repository.getSkill(name);
String resource = skill == null ? null : skill.getResource(path);
if (resource == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_RESOURCE_NOT_FOUND", "Skill 资源不存在");
}
return resource;
}
/**
* 设置 Skill 启用状态。
*
* @param name Skill 名称
* @param enabled 是否启用
*/
public void setEnabled(String name, boolean enabled) {
int updated = jdbc.sql("""
UPDATE app.skill_config SET enabled = :enabled, updated_at = CURRENT_TIMESTAMP
WHERE skill_name = :name AND validation_status = 'VALID'
""")
.param("enabled", enabled)
.param("name", name)
.update();
if (updated != 1) {
throw new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在或校验未通过");
}
}
/**
* 返回当前启用的 Skill 名称。
*
* @return Skill 名称数组
*/
public String[] enabledNames() {
return jdbc.sql("""
SELECT skill_name FROM app.skill_config
WHERE enabled AND validation_status = 'VALID'
ORDER BY skill_name
""")
.query(String.class)
.list()
.toArray(String[]::new);
}
/**
* 导入管理员上传的标准 Skill ZIP。
*
* @param file ZIP 文件
* @param principal 当前用户
* @return 导入后的 Skill
*/
@Transactional
public SkillView importZip(MultipartFile file, Principal principal) {
if (file.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_EMPTY", "Skill 压缩包为空");
}
Path importRoot = properties.dataRoot().toAbsolutePath().normalize().resolve("skill-imports");
try {
Files.createDirectories(importRoot);
Path temporary = Files.createTempDirectory(importRoot, "skill-");
try {
unzip(file, temporary);
Path skillRoot = locateSkillRoot(temporary);
SkillPackageReader.SkillPackage skillPackage = packageReader.read(skillRoot, "imported");
String name = skillPackage.skill().getName();
if (repository.skillExists(name)) {
throw new ApiException(HttpStatus.CONFLICT, "SKILL_NAME_CONFLICT", "同名 Skill 已存在");
}
repository.save(List.of(skillPackage.skill()), false);
UUID userId = userService.requireUserId(principal.getName());
jdbc.sql("""
INSERT INTO app.skill_config(
skill_name, version, source_type, enabled, read_only, checksum,
validation_status, imported_by)
VALUES (:name, :version, 'IMPORTED', FALSE, TRUE, :checksum, 'VALID', :userId)
""")
.param("name", name)
.param("version", skillPackage.version())
.param("checksum", skillPackage.checksum())
.param("userId", userId)
.update();
return require(name).view();
} finally {
deleteTree(temporary);
}
} catch (IOException exception) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_IMPORT_FAILED", "Skill 压缩包读取失败");
}
}
/**
* 删除管理员导入且已停用的 Skill。
*
* @param name Skill 名称
*/
@Transactional
public void deleteImported(String name) {
String sourceType = jdbc.sql("SELECT source_type FROM app.skill_config WHERE skill_name = :name")
.param("name", name)
.query(String.class)
.optional()
.orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "SKILL_NOT_FOUND", "Skill 不存在"));
if (!"IMPORTED".equals(sourceType)) {
throw new ApiException(HttpStatus.CONFLICT, "BUILTIN_SKILL_READ_ONLY", "内置 Skill 不能删除");
}
repository.delete(name);
}
/**
* 解压 Skill 包,并忽略 macOS 与 Python 生成的无关元数据。
*
* @param file ZIP 文件
* @param destination 解压目录
* @throws IOException ZIP 读取或文件写入失败时抛出
*/
static void unzip(MultipartFile file, Path destination) throws IOException {
int entries = 0;
long total = 0;
Path root = destination.toAbsolutePath().normalize();
try (InputStream source = file.getInputStream(); ZipInputStream zip = new ZipInputStream(source, java.nio.charset.StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = zip.getNextEntry()) != null) {
String entryName = entry.getName().replace('\\', '/');
Path target = root.resolve(entryName).normalize();
if (!target.startsWith(root)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_PATH_INVALID", "Skill 压缩包包含越界路径");
}
if (isIgnoredArchiveEntry(entryName)) {
continue;
}
if (++entries > MAX_ZIP_ENTRIES) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_ZIP_TOO_MANY_FILES", "Skill 压缩包文件数量超过限制");
}
if (entry.isDirectory()) {
Files.createDirectories(target);
continue;
}
Files.createDirectories(target.getParent());
try (java.io.OutputStream output = Files.newOutputStream(target)) {
int read;
while ((read = zip.read(buffer)) != -1) {
total += read;
if (total > MAX_UNCOMPRESSED_BYTES) {
throw new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, "SKILL_ZIP_TOO_LARGE", "Skill 解压后大小超过限制");
}
output.write(buffer, 0, read);
}
}
}
}
}
/**
* 根据唯一的 SKILL.md 定位 Skill 根目录。
*
* @param temporary ZIP 解压目录
* @return Skill 根目录
* @throws IOException 目录遍历失败时抛出
*/
static Path locateSkillRoot(Path temporary) throws IOException {
if (Files.isRegularFile(temporary.resolve("SKILL.md"))) {
return temporary;
}
List<Path> candidates;
try (var paths = Files.walk(temporary)) {
candidates = paths
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().equals("SKILL.md"))
.map(Path::getParent)
.distinct()
.toList();
}
if (candidates.size() == 1) {
return candidates.getFirst();
}
if (candidates.isEmpty()) {
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中必须包含 SKILL.md");
}
throw new ApiException(HttpStatus.BAD_REQUEST, "SKILL_STRUCTURE_INVALID", "压缩包中包含多个 Skill请每次仅导入一个");
}
private static boolean isIgnoredArchiveEntry(String entryName) {
String lowerName = entryName.toLowerCase(Locale.ROOT);
if (lowerName.endsWith(".pyc")) {
return true;
}
for (String part : entryName.split("/")) {
if (part.equals("__MACOSX")
|| part.equals(".DS_Store")
|| part.equals("__pycache__")
|| part.startsWith("._")) {
return true;
}
}
return false;
}
private void deleteTree(Path root) {
if (root == null || !Files.exists(root)) {
return;
}
try {
for (Path path : Files.walk(root).sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
} catch (IOException ignored) {
// 临时目录清理失败不覆盖主要导入结果,后续维护任务可清理。
}
}
private static SkillView mapSkill(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new SkillView(
rs.getString("name"),
rs.getString("description"),
rs.getString("version"),
rs.getString("source_type"),
rs.getBoolean("enabled"),
rs.getBoolean("read_only"),
rs.getString("validation_status"),
rs.getString("validation_message"),
rs.getObject("updated_at", OffsetDateTime.class));
}
private static final String SKILL_SELECT = """
SELECT s.name, s.description, c.version, c.source_type, c.enabled, c.read_only,
c.validation_status, c.validation_message, c.updated_at
FROM agentscope.agentscope_skills s
JOIN app.skill_config c ON c.skill_name = s.name
""";
/**
* Skill 列表视图。
*
* @param name 标准名称
* @param description 描述
* @param version 版本
* @param sourceType 来源
* @param enabled 是否启用
* @param readOnly 是否只读
* @param validationStatus 校验状态
* @param validationMessage 校验信息
* @param updatedAt 更新时间
*/
public record SkillView(
String name,
String description,
String version,
String sourceType,
boolean enabled,
boolean readOnly,
String validationStatus,
String validationMessage,
OffsetDateTime updatedAt) {
}
/**
* Skill 详情。
*
* @param view 基本信息
* @param content SKILL.md 全文
* @param resources 资源路径
*/
public record SkillDetail(SkillView view, String content, List<String> resources) {
}
}

View File

@@ -0,0 +1,49 @@
spring:
application:
name: smart-factory-approval-agent
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:54330/smart_factory_agent}
username: ${SPRING_DATASOURCE_USERNAME:smart_factory}
password: ${SPRING_DATASOURCE_PASSWORD:smart_factory}
hikari:
maximum-pool-size: 12
minimum-idle: 2
flyway:
enabled: true
validate-on-migrate: true
servlet:
multipart:
max-file-size: 150MB
max-request-size: 160MB
threads:
virtual:
enabled: true
jackson:
default-property-inclusion: non_null
server:
port: ${SERVER_PORT:8080}
servlet:
session:
cookie:
http-only: true
same-site: lax
secure: ${SESSION_COOKIE_SECURE:false}
app:
data-root: ${APP_DATA_ROOT:file:../data}
deepseek-key-file: ${DEEPSEEK_KEY_FILE:./deepseek_key.txt}
dashscope-key-file: ${DASHSCOPE_KEY_FILE:./dashscope_key.txt}
master-key: ${APP_MASTER_KEY:smart-factory-local-master-key}
admin-username: ${APP_ADMIN_USERNAME:admin}
admin-password: ${APP_ADMIN_PASSWORD:admin123}
model-base-url: ${APP_MODEL_BASE_URL:https://api.deepseek.com}
model-id: ${APP_MODEL_ID:deepseek-v4-flash}
model-context-window: ${APP_MODEL_CONTEXT_WINDOW:131072}
sandbox-image: ${APP_SANDBOX_IMAGE:smart-factory-agent-runtime:0.1.0}
sandbox-network: ${APP_SANDBOX_NETWORK:bridge}
run-timeout: ${APP_RUN_TIMEOUT:60m}
logging:
pattern:
level: "%5p [trace:%X{traceId:-}]"

View File

@@ -0,0 +1,246 @@
CREATE SCHEMA app;
CREATE SCHEMA agentscope;
CREATE TABLE app.app_user (
id UUID PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE app.model_config (
id UUID PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
provider VARCHAR(32) NOT NULL,
base_url VARCHAR(500) NOT NULL,
model_id VARCHAR(255) NOT NULL,
api_key_ciphertext BYTEA,
api_key_hint VARCHAR(16),
key_version SMALLINT,
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
capabilities_json JSONB NOT NULL DEFAULT '{}'::jsonb,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID REFERENCES app.app_user(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_model_provider CHECK (provider IN ('DASHSCOPE', 'OPENAI_COMPATIBLE')),
CONSTRAINT ck_model_key_pair CHECK (
(api_key_ciphertext IS NULL AND key_version IS NULL)
OR (api_key_ciphertext IS NOT NULL AND key_version IS NOT NULL)
),
CONSTRAINT ck_model_config_json CHECK (jsonb_typeof(config_json) = 'object'),
CONSTRAINT ck_model_capabilities_json CHECK (jsonb_typeof(capabilities_json) = 'object')
);
CREATE UNIQUE INDEX uk_model_config_default ON app.model_config (is_default) WHERE is_default;
CREATE TABLE app.model_assignment (
role VARCHAR(32) PRIMARY KEY,
model_config_id UUID NOT NULL REFERENCES app.model_config(id),
assigned_by UUID REFERENCES app.app_user(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_model_assignment_role CHECK (role IN ('ORCHESTRATION', 'WRITING', 'REVIEW'))
);
CREATE INDEX ix_model_assignment_model ON app.model_assignment (model_config_id);
CREATE TABLE app.project (
id UUID PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
project_name VARCHAR(255) NOT NULL,
agui_thread_id VARCHAR(255) NOT NULL UNIQUE,
application_level VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'MATERIAL_CHECK',
created_by UUID NOT NULL REFERENCES app.app_user(id),
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_project_application_level CHECK (application_level IN ('ADVANCED', 'EXCELLENT')),
CONSTRAINT ck_project_status CHECK (
status IN ('MATERIAL_CHECK', 'PLANNING', 'WRITING', 'DELIVERED', 'FAILED', 'ARCHIVED')
),
CONSTRAINT ck_project_version CHECK (version >= 0)
);
CREATE INDEX ix_project_updated_at ON app.project (updated_at DESC);
CREATE TABLE app.project_file (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
original_name VARCHAR(500) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
extension VARCHAR(32) NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'READY',
uploaded_by UUID NOT NULL REFERENCES app.app_user(id),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_project_file_path UNIQUE (project_id, relative_path),
CONSTRAINT ck_project_file_size CHECK (size_bytes >= 0),
CONSTRAINT ck_project_file_status CHECK (status IN ('UPLOADING', 'READY', 'FAILED', 'DELETED')),
CONSTRAINT ck_project_file_relative_path CHECK (
relative_path <> ''
AND left(relative_path, 1) <> '/'
AND relative_path !~ '(^|/)\.\.(/|$)'
)
);
CREATE INDEX ix_project_file_project ON app.project_file (project_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE TABLE app.project_plan (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
plan_version INTEGER NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
plan_json JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES app.app_user(id),
confirmed_by UUID REFERENCES app.app_user(id),
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_project_plan_version UNIQUE (project_id, plan_version),
CONSTRAINT ck_project_plan_version CHECK (plan_version > 0),
CONSTRAINT ck_project_plan_status CHECK (status IN ('DRAFT', 'CONFIRMED', 'SUPERSEDED')),
CONSTRAINT ck_project_plan_json CHECK (jsonb_typeof(plan_json) = 'object'),
CONSTRAINT ck_project_plan_confirmation CHECK (
(status = 'CONFIRMED' AND confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL)
OR (status <> 'CONFIRMED')
)
);
CREATE UNIQUE INDEX uk_project_plan_confirmed ON app.project_plan (project_id)
WHERE status = 'CONFIRMED';
CREATE TABLE app.agent_run (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
parent_run_id UUID,
model_config_id UUID REFERENCES app.model_config(id),
trigger_type VARCHAR(24) NOT NULL,
status VARCHAR(24) NOT NULL,
pending_interrupt JSONB,
trace_id VARCHAR(100) NOT NULL,
error_code VARCHAR(100),
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_agent_run_project UNIQUE (id, project_id),
CONSTRAINT fk_agent_run_parent_project
FOREIGN KEY (parent_run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_agent_run_trigger CHECK (trigger_type IN ('INITIAL', 'RESUME', 'RETRY')),
CONSTRAINT ck_agent_run_status CHECK (
status IN ('RUNNING', 'WAITING_INPUT', 'COMPLETED', 'FAILED', 'CANCELLED', 'INTERRUPTED')
),
CONSTRAINT ck_agent_run_pending_json CHECK (
pending_interrupt IS NULL OR jsonb_typeof(pending_interrupt) = 'object'
),
CONSTRAINT ck_agent_run_pending_status CHECK (
(status = 'WAITING_INPUT' AND pending_interrupt IS NOT NULL)
OR (status <> 'WAITING_INPUT' AND pending_interrupt IS NULL)
),
CONSTRAINT ck_agent_run_end CHECK (
(status = 'RUNNING' AND ended_at IS NULL)
OR (status IN ('WAITING_INPUT', 'COMPLETED', 'FAILED', 'CANCELLED', 'INTERRUPTED')
AND ended_at IS NOT NULL)
)
);
CREATE INDEX ix_agent_run_project ON app.agent_run (project_id, started_at DESC);
CREATE UNIQUE INDEX uk_agent_run_active ON app.agent_run (project_id)
WHERE status IN ('RUNNING', 'WAITING_INPUT');
CREATE TABLE app.agent_event (
id BIGSERIAL PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
run_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_id VARCHAR(255),
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_agent_event_run_project
FOREIGN KEY (run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_agent_event_payload CHECK (jsonb_typeof(payload) = 'object')
);
CREATE INDEX ix_agent_event_project ON app.agent_event (project_id, id);
CREATE INDEX ix_agent_event_run ON app.agent_event (run_id, id);
CREATE TABLE app.artifact (
id UUID PRIMARY KEY,
project_id UUID NOT NULL REFERENCES app.project(id),
run_id UUID,
kind VARCHAR(32) NOT NULL,
name VARCHAR(500) NOT NULL,
relative_path VARCHAR(1000) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
published_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_artifact_path UNIQUE (project_id, relative_path),
CONSTRAINT fk_artifact_run_project
FOREIGN KEY (run_id, project_id) REFERENCES app.agent_run(id, project_id),
CONSTRAINT ck_artifact_kind CHECK (kind IN ('DOCX', 'PLANNING_REPORT', 'OTHER')),
CONSTRAINT ck_artifact_size CHECK (size_bytes > 0),
CONSTRAINT ck_artifact_metadata CHECK (jsonb_typeof(metadata_json) = 'object'),
CONSTRAINT ck_artifact_relative_path CHECK (
relative_path <> ''
AND left(relative_path, 1) <> '/'
AND relative_path !~ '(^|/)\.\.(/|$)'
)
);
CREATE INDEX ix_artifact_project ON app.artifact (project_id, published_at DESC);
CREATE TABLE agentscope.agentscope_skills (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT NOT NULL,
skill_content TEXT NOT NULL,
source VARCHAR(255) NOT NULL,
metadata_json TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE agentscope.agentscope_skill_resources (
id BIGINT NOT NULL REFERENCES agentscope.agentscope_skills(id) ON DELETE CASCADE,
resource_path VARCHAR(500) NOT NULL,
resource_content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, resource_path)
);
CREATE TABLE app.skill_config (
skill_name VARCHAR(255) PRIMARY KEY
REFERENCES agentscope.agentscope_skills(name) ON DELETE CASCADE,
version VARCHAR(64),
source_type VARCHAR(16) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
read_only BOOLEAN NOT NULL DEFAULT TRUE,
checksum CHAR(64) NOT NULL,
validation_status VARCHAR(16) NOT NULL DEFAULT 'VALID',
validation_message TEXT,
imported_by UUID REFERENCES app.app_user(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_skill_source_type CHECK (source_type IN ('BUILTIN', 'IMPORTED')),
CONSTRAINT ck_skill_validation_status CHECK (validation_status IN ('VALID', 'INVALID')),
CONSTRAINT ck_skill_read_only CHECK (source_type <> 'BUILTIN' OR read_only)
);
CREATE INDEX ix_skill_config_enabled ON app.skill_config (enabled, skill_name) WHERE enabled;

View File

@@ -0,0 +1,39 @@
# 角色
你是智能工厂申报材料 Agent。你负责使用企业材料、百炼知识库、已启用 Skills 和项目工作区,形成建设规划、申报书审阅稿和评审结果。
# 语言
默认使用简体中文进行用户可见回复、执行说明、事实台账、建设规划和申报书编写。仅代码、命令、文件路径、标准原文、产品型号及无法准确翻译的专有名词保留原语言;用户明确要求其他语言时才切换。
# 事实与规划边界
- C企业材料或用户确认的企业事实。材料已经覆盖的字段必须忠实使用不得改写、美化或用案例替换。
- E企业公开资料必须保留来源并标记待企业确认。
- R政策、标准、行业方法和同行案例只用于支撑规划。
- P基于 R 形成的未来规划、建议目标和测算假设。规划确认后成为全书统一基线。
- U缺失、冲突或无法核实的企业现状写为待企业确认并进入 DOCX 原生批注。
- C/E/R/P/U 仅用于内部事实台账,不得出现在面向用户的 DOCX统一转换为“已确认 / 待核实 / 待确认”等可读口径。
材料未覆盖的建设内容,应主动形成具体、完整、可执行的 P。自由生成仅限未来规划不得编造企业当前设备、系统、营收、能耗、认证和既有成效。
未知企业现状必须使用“需确认是否……”“待企业提供……”等非断言句式。严禁先写成已发生、已具备或已承诺的肯定事实,再在句尾附“待确认”;真实性承诺也只能写为待签署或待提供。
# 自主执行
1. 先递归查看 `inputs/`,保留并利用上传目录、原文件名和材料分类之间的语义关系;同名文件必须结合完整相对路径判断来源。
2. 主动选择与文件类型相符的 PDF、PPTX、XLS/XLSX、DOCX 等文档 Skill先读取 Skill 的完整 `SKILL.md`,再按其方法做结构化读取。不得只凭文件名推断正文。
3. `document_view` 是按需视觉补充工具,不是默认步骤。仅当文档 Skill 提取结果明显不足、页面为扫描件,或 PDF/PPT/工作表的图示、布局、截图对判断重要时,才使用自身视觉能力查看实际页面。由你决定页码、幻灯片、工作表与范围;建议每次最多渲染 5 张,可分批调用。大型工作表应主动拆分 range 查看,无需模拟滚动。
4. 判断输入与事实充分程度,主动检索知识库并选择必要业务 Skill。
5. 先读企业材料,再用知识库补充政策、标准、行业方法和规划依据。缺少企业现状时保持未知,不得让知识库或同行案例冒充企业事实。
6. 将事实写入 work/facts将规划锚点写入 work/plans将引用写入 references。
材料检验阶段生成 `work/facts/material-check.json`;规划阶段生成 `work/plans/proposed-plan.json`
两者必须来自当前企业材料与本次知识库分析,不得套用固定企业方案。
阶段任务指定的结构化文件是必需产物。获得最小事实后应先写入合法初稿,再随分析持续更新;不得把必需产物推迟到全部可选读取和视觉检查之后。
7. 规划确认后保持名称、架构、场景、KPI、投资、周期和术语前后一致。
8. 常规工作区读写、搜索和 Shell 可自主执行;所有操作限定在 Docker 工作区内。不得探测宿主机、读取凭证、修改系统配置或访问与任务无关的网络服务;知识库访问只通过已启用的 RAG Skill。
9. 工具返回失败、非零退出码、参数错误或文件冲突时,将错误结果视为可诊断观察;分析原因,修正参数、路径或前置条件后重试,也可选择替代工具。单个工具失败不得直接结束 Run。只有安全策略拒绝、用户停止、模型重连耗尽或确认无可恢复路径时才终止且不得返回假成功。
10. 页面输出只保留业务结论、待确认事项和必要依据。不要汇报后台 JSON、内部相对或绝对路径、编码或 Markdown 格式、命令、Skill 名称、工具调用、校验过程,以及“已写入”“已保存到某文件”等内部执行细节;这些操作由执行信息流单独展示。
# 完成条件
只有规划已确认、事实与规划口径一致、未知企业事实已转为批注、DOCX 通过打开与结构校验后,才可发布产物。申报书正文目标为 1 万至 2 万汉字应完整展开建设背景、现状与差距、总体架构、建设场景、数据与系统集成、实施路径、投资与效益、保障机制等内容。关键未知企业事实允许待确认能够由标准、知识库、Skill 和已确认规划形成的未来场景、技术路径、阶段任务与建议指标必须充分写实,不得用批注或空泛表述代替正文。不得把 Markdown 表格分隔行写入 Word 表格。中文字体须使用英文族名 `SimSun`(正文)与 `SimHei`(标题),同一文本运行的 ascii、hAnsi 与 eastAsia 均须使用对应字体,避免跨平台渲染为方框。当前不要求生成目录,不要创建仅含 TOC 域且需要办公软件手动更新的空目录;须检查表格换行、页码与批注锚点。每个 Word 原生批注 ID 只能锚定一处,正文中对应的 commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一待确认问题若在多处出现,必须复制批注正文并为每处使用新的唯一 ID。

View File

@@ -0,0 +1,9 @@
请将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
1. 企业名称、申报等级,以及 C已确认事实、E外部依据、R规划建议、P待实施、U待确认边界。
2. 已确认并冻结的建设规划包括建设方向、场景、KPI、投资区间、建设周期和版本。
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。
5. 当前任务进度、失败原因、尚未完成的动作和最安全的下一步。
不得把知识库内容改写为企业事实,不得把规划建议改写为已建成现状。省略寒暄、重复过程和可从工作区重新读取的大段工具输出。