init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 生成的建设规划结构不完整,请重试材料检验");
|
||||
}
|
||||
}
|
||||
@@ -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、completeness(0-100 整数)、confirmedFacts(字符串数组)和 missingItems(对象数组)。
|
||||
每个 missingItems 对象必须包含 id、label、reason、required。即使仅有企业名称,也要给出可继续规划的最小缺口清单。
|
||||
暂时不要生成建设规划或 DOCX。
|
||||
完成后仅输出材料覆盖、已确认事实和待确认缺口,不说明内部文件、格式、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), fileSummary);
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
ObjectNode report = readMaterialCheckWithRepair(project, run);
|
||||
ObjectNode ask = objectMapper.createObjectNode();
|
||||
ask.put("kind", "material_check");
|
||||
ask.put("interruptId", "materials-" + run.id());
|
||||
ask.put("title", "确认材料检验");
|
||||
ask.put("description", "确认缺口处理方式后生成建设规划");
|
||||
ask.set("report", report);
|
||||
finishWaiting(project.id(), run.id(), ask);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取材料检验结果;Agent 正常结束但未写出有效文件时,把校验错误反馈给同一线程继续修复。
|
||||
*
|
||||
* @param project 当前项目
|
||||
* @param run 当前 Run
|
||||
* @return 有效材料检验结果
|
||||
* @throws IOException 修复后产物仍无法读取时抛出
|
||||
*/
|
||||
private ObjectNode readMaterialCheckWithRepair(ProjectService.ProjectView project, RunView run)
|
||||
throws IOException {
|
||||
int repairRound = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return outputService.readMaterialCheck(project.id());
|
||||
} catch (ApiException | IOException 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、planningYears(1-10 的整数)、investmentRange、
|
||||
applicationLevel、scenarios(字符串数组)、aiScenarioCount(整数)和 assumptions(字符串数组)。
|
||||
暂时不要编写最终 DOCX。
|
||||
完成后仅输出用户确认规划所需的方向、周期、投资、场景和假设,不说明内部文件、格式、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), materialResponse.toString());
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
ObjectNode plan = readProposedPlanWithRepair(project, run);
|
||||
ProjectService.PlanView saved = projectService.saveDraftPlan(project.id(), plan, userId);
|
||||
ObjectNode ask = objectMapper.createObjectNode();
|
||||
ask.put("kind", "planning");
|
||||
ask.put("interruptId", "plan-" + saved.id());
|
||||
ask.put("title", "确认建设规划");
|
||||
ask.put("description", "确认后 Agent 将自主完成申报书编写与评审");
|
||||
ask.set("plan", plan);
|
||||
ask.put("planId", saved.id().toString());
|
||||
finishWaiting(project.id(), run.id(), ask);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取建议规划;结构无效时把原因反馈给同一线程继续修复。
|
||||
*
|
||||
* @param project 当前项目
|
||||
* @param run 当前 Run
|
||||
* @return 有效建设规划
|
||||
* @throws IOException 文件无法读取时抛出
|
||||
*/
|
||||
private ObjectNode readProposedPlanWithRepair(ProjectService.ProjectView project, RunView run)
|
||||
throws IOException {
|
||||
int repairRound = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return outputService.readProposedPlan(project.id());
|
||||
} catch (ApiException | IOException 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、planningYears(1-10 整数)、
|
||||
investmentRange、applicationLevel、scenarios(非空数组)、aiScenarioCount 和 assumptions。
|
||||
完成有效文件后再结束本轮。
|
||||
""".formatted(exception.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void executeWritingRun(
|
||||
ProjectService.ProjectView project,
|
||||
ProjectService.PlanView plan,
|
||||
RunView run,
|
||||
boolean resuming) {
|
||||
try {
|
||||
eventService.append(project.id(), run.id(), "RUN_STARTED", Map.of(
|
||||
"threadId", project.threadId(), "runId", run.id(), "phase", "WRITING"));
|
||||
String prompt = """
|
||||
建设规划已确认。企业:%s,申报等级:%s。
|
||||
确认规划 JSON:%s
|
||||
请自主完成完整申报书:调用分章编写、总结、评审和 docx Skill;按需调用百炼知识库。
|
||||
你拥有当前项目工作区的读、写和 shell 能力。最终文件必须保存到 artifacts/,扩展名为 .docx。
|
||||
任何有企业材料可核对的内容必须据实;缺少企业事实的内容允许结合知识库形成合理方案,且在 Word 原生批注中标明待确认。
|
||||
正文目标为 1 万至 2 万汉字。关键现状未知可以待确认;建设场景、技术路线、实施阶段、建议 KPI 和保障机制等未来规划必须结合材料、知识库和 Skill 充分展开,不能以待确认占位代替可合理形成的方案。
|
||||
最终事实审计必须逐句检查:未知企业现状使用“需确认是否……”或“待企业提供……”等非断言句式,严禁先写肯定事实再附待确认批注;企业承诺必须写为待签署或待提供。
|
||||
每个 Word 原生批注 ID 只能锚定一处,commentRangeStart、commentRangeEnd、commentReference 必须各出现且仅出现一次;同一问题在多处引用时必须复制批注正文并分配新的唯一 ID。
|
||||
即使 artifacts/ 已有旧稿,也必须为本轮重新生成并覆盖 DOCX,再执行结构、一致性和事实边界校验。
|
||||
请保持章节口径一致,完成评审后再交付。
|
||||
最终回复只说明申报书完成情况和用户需要关注的确认事项,不汇报内部文件、工具、命令、落盘或校验过程。
|
||||
""".formatted(project.companyName(), project.applicationLevel(), plan.plan().toString());
|
||||
streamAgent(project, run, recoveryPrompt(prompt, resuming));
|
||||
|
||||
JsonNode metadata = objectMapper.valueToTree(Map.of(
|
||||
"planVersion", plan.version(),
|
||||
"companyName", project.companyName(),
|
||||
"review", "DOCX 已通过结构与一致性校验"));
|
||||
publishAndComplete(project.id(), run, metadata);
|
||||
} catch (Exception exception) {
|
||||
failUnlessInterrupted(run, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void streamAgent(ProjectService.ProjectView project, RunView run, String prompt) {
|
||||
RunControl control = activeRuns.get(run.id());
|
||||
executionService.execute(
|
||||
project,
|
||||
run,
|
||||
"""
|
||||
用户可见正文和执行说明默认使用简体中文;代码、命令、文件路径、标准原文和专有名词可保留原语言。
|
||||
|
||||
""" + prompt,
|
||||
control == null ? reactor.core.publisher.Mono.never() : control.stopSignal.asMono(),
|
||||
() -> runStore.ensureRunning(run.id()),
|
||||
() -> runStore.isInterrupted(run.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将运行切换为等待输入。
|
||||
*
|
||||
* @param projectId 项目 ID
|
||||
* @param runId Run ID
|
||||
* @param interrupt Ask 内容
|
||||
*/
|
||||
private void finishWaiting(UUID projectId, UUID runId, JsonNode interrupt) {
|
||||
transactions.executeWithoutResult(status -> {
|
||||
eventService.append(projectId, runId, "ASK_REQUESTED", interrupt);
|
||||
int updated = 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
""";
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+ ")。]";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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", "登录账户不存在"));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "模型密钥解密失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
49
server/src/main/resources/application.yml
Normal file
49
server/src/main/resources/application.yml
Normal 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:-}]"
|
||||
246
server/src/main/resources/db/migration/V1__initial_schema.sql
Normal file
246
server/src/main/resources/db/migration/V1__initial_schema.sql
Normal 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;
|
||||
@@ -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。
|
||||
@@ -0,0 +1,9 @@
|
||||
请将当前会话压缩为可继续执行的结构化工作记忆。必须保留:
|
||||
|
||||
1. 企业名称、申报等级,以及 C(已确认事实)、E(外部依据)、R(规划建议)、P(待实施)、U(待确认)边界。
|
||||
2. 已确认并冻结的建设规划,包括建设方向、场景、KPI、投资区间、建设周期和版本。
|
||||
3. 材料之间的冲突、全部未解决 U 项、Word 批注要求和事实约束。
|
||||
4. 已调用 Skill、关键工具结果、已生成或已修改的工作区文件及其校验状态。
|
||||
5. 当前任务进度、失败原因、尚未完成的动作和最安全的下一步。
|
||||
|
||||
不得把知识库内容改写为企业事实,不得把规划建议改写为已建成现状。省略寒暄、重复过程和可从工作区重新读取的大段工具输出。
|
||||
Reference in New Issue
Block a user