feat(chat): cut over to single SSE endpoint

This commit is contained in:
zhuyongxin
2026-07-22 10:01:12 +08:00
parent f8809cb7dd
commit bc36248cd8
36 changed files with 2187 additions and 945 deletions
@@ -0,0 +1,119 @@
package com.superbiz.agent.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/** Central limits and bounded executor settings for the production Chat Harness graph. */
@Getter
@Setter
@ConfigurationProperties(prefix = "harness.chat")
public class ChatHarnessProperties {
private int workerCorePoolSize = 2;
private int workerMaxPoolSize = 8;
private int workerQueueCapacity = 64;
private int modelCorePoolSize = 4;
private int modelMaxPoolSize = 8;
private int modelQueueCapacity = 32;
private String toolKeyPrefix = "superbiz:harness:tool-call";
private Duration runTimeout = Duration.ofMinutes(5);
private Duration sseTimeout = Duration.ofMinutes(5);
private Duration canonicalTtl = Duration.ofHours(2);
private long canonicalMaxRecordBytes = 1_048_576;
private long canonicalMaxAgentResultBytes = 65_536;
private int maxModelCalls = 24;
private int maxToolCalls = 24;
private int maxCallsPerTool = 8;
private long maxInputTokens = 100_000;
private long maxOutputTokens = 100_000;
private long maxTotalTokens = 200_000;
private long maxRunBytes = 1_000_000;
private long diagnosisMaxQueryBytes = 16_384;
private long diagnosisMaxPreviousTurnBytes = 16_384;
private long diagnosisMaxInputBytes = 49_152;
private long diagnosisMaxDraftBytes = 49_152;
private long routerMaxInputBytes = 16_384;
private long routerMaxOutputBytes = 4_096;
private Duration routerAttemptTimeout = Duration.ofSeconds(10);
private Duration routerTotalTimeout = Duration.ofSeconds(25);
private long systemMaxInputBytes = 16_384;
private long systemMaxOutputBytes = 16_384;
private Duration systemTimeout = Duration.ofSeconds(30);
private long knowledgeMaxModelInputBytes = 32_768;
private long knowledgeMaxModelOutputBytes = 24_576;
private Duration knowledgeModelTimeout = Duration.ofSeconds(30);
private long semanticMaxInputBytes = 100_000;
private long semanticMaxOutputBytes = 10_000;
private Duration semanticAttemptTimeout = Duration.ofSeconds(20);
private Duration semanticTotalTimeout = Duration.ofSeconds(45);
private long repairMaxInputBytes = 100_000;
private long repairMaxOutputBytes = 48_000;
private Duration repairTimeout = Duration.ofSeconds(20);
public void validate() {
positive(workerCorePoolSize, "workerCorePoolSize");
positive(workerMaxPoolSize, "workerMaxPoolSize");
positive(workerQueueCapacity, "workerQueueCapacity");
positive(modelCorePoolSize, "modelCorePoolSize");
positive(modelMaxPoolSize, "modelMaxPoolSize");
positive(modelQueueCapacity, "modelQueueCapacity");
if (workerCorePoolSize > workerMaxPoolSize || modelCorePoolSize > modelMaxPoolSize) {
throw new IllegalArgumentException("executor core size must not exceed max size");
}
positive(canonicalMaxRecordBytes, "canonicalMaxRecordBytes");
positive(canonicalMaxAgentResultBytes, "canonicalMaxAgentResultBytes");
if (canonicalMaxAgentResultBytes > canonicalMaxRecordBytes) {
throw new IllegalArgumentException("canonical agent result exceeds record limit");
}
positive(maxModelCalls, "maxModelCalls");
positive(maxToolCalls, "maxToolCalls");
positive(maxCallsPerTool, "maxCallsPerTool");
positive(maxInputTokens, "maxInputTokens");
positive(maxOutputTokens, "maxOutputTokens");
positive(maxTotalTokens, "maxTotalTokens");
positive(maxRunBytes, "maxRunBytes");
positive(diagnosisMaxQueryBytes, "diagnosisMaxQueryBytes");
positive(diagnosisMaxPreviousTurnBytes, "diagnosisMaxPreviousTurnBytes");
positive(diagnosisMaxInputBytes, "diagnosisMaxInputBytes");
positive(diagnosisMaxDraftBytes, "diagnosisMaxDraftBytes");
positive(routerMaxInputBytes, "routerMaxInputBytes");
positive(routerMaxOutputBytes, "routerMaxOutputBytes");
positive(systemMaxInputBytes, "systemMaxInputBytes");
positive(systemMaxOutputBytes, "systemMaxOutputBytes");
positive(knowledgeMaxModelInputBytes, "knowledgeMaxModelInputBytes");
positive(knowledgeMaxModelOutputBytes, "knowledgeMaxModelOutputBytes");
positive(semanticMaxInputBytes, "semanticMaxInputBytes");
positive(semanticMaxOutputBytes, "semanticMaxOutputBytes");
positive(repairMaxInputBytes, "repairMaxInputBytes");
positive(repairMaxOutputBytes, "repairMaxOutputBytes");
requirePositive(runTimeout, "runTimeout");
requirePositive(sseTimeout, "sseTimeout");
requirePositive(canonicalTtl, "canonicalTtl");
requirePositive(routerAttemptTimeout, "routerAttemptTimeout");
requirePositive(routerTotalTimeout, "routerTotalTimeout");
requirePositive(systemTimeout, "systemTimeout");
requirePositive(knowledgeModelTimeout, "knowledgeModelTimeout");
requirePositive(semanticAttemptTimeout, "semanticAttemptTimeout");
requirePositive(semanticTotalTimeout, "semanticTotalTimeout");
requirePositive(repairTimeout, "repairTimeout");
if (routerTotalTimeout.compareTo(routerAttemptTimeout) < 0
|| semanticTotalTimeout.compareTo(semanticAttemptTimeout) < 0) {
throw new IllegalArgumentException("total timeout must not be shorter than attempt timeout");
}
}
private static void positive(long value, String name) {
if (value <= 0) {
throw new IllegalArgumentException(name + " must be positive");
}
}
private static void requirePositive(Duration value, String name) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(name + " must be positive");
}
}
}
@@ -0,0 +1,344 @@
package com.superbiz.agent.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.config.MysqlToolProperties.DataSourceProperties;
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
import com.superbiz.agent.harness.agent.DiagnosisAgentFactory;
import com.superbiz.agent.harness.agent.DiagnosisAgentLimits;
import com.superbiz.agent.harness.agent.DiagnosisAgentUseCase;
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
import com.superbiz.agent.harness.application.executor.DiagnosisChatExecutor;
import com.superbiz.agent.harness.application.executor.KnowledgeQueryExecutor;
import com.superbiz.agent.harness.application.executor.KnowledgeQueryLimits;
import com.superbiz.agent.harness.application.executor.SingleTurnExecutorLimits;
import com.superbiz.agent.harness.application.executor.SystemChatExecutor;
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
import com.superbiz.agent.harness.application.persistence.PreviousTurnLimits;
import com.superbiz.agent.harness.application.persistence.PublishedResultPolicy;
import com.superbiz.agent.harness.application.routing.IntentRouter;
import com.superbiz.agent.harness.application.routing.IntentRouterLimits;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.guard.evidence.EvidenceGuard;
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
import com.superbiz.agent.harness.guard.semantic.SemanticGuard;
import com.superbiz.agent.harness.guard.semantic.SemanticGuardLimits;
import com.superbiz.agent.harness.release.DiagnosisReleaseUseCase;
import com.superbiz.agent.harness.release.EvidenceRepair;
import com.superbiz.agent.harness.release.EvidenceRepairLimits;
import com.superbiz.agent.harness.release.SafeFallbackFactory;
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.harness.tool.adapter.MysqlToolAdapter;
import com.superbiz.agent.harness.tool.adapter.QueryLogsToolAdapter;
import com.superbiz.agent.harness.tool.adapter.RagToolAdapter;
import com.superbiz.agent.harness.tool.boundary.ToolBoundary;
import com.superbiz.agent.harness.tool.mysql.JdbcMysqlReadOnlyExecutor;
import com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition;
import com.superbiz.agent.harness.tool.mysql.MysqlReadOnlyExecutor;
import com.superbiz.agent.harness.tool.mysql.MysqlResultProjector;
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
import com.superbiz.agent.harness.tool.projection.QueryLogsResultProjector;
import com.superbiz.agent.harness.tool.projection.RagResultProjector;
import com.superbiz.agent.harness.tool.projection.ToolProjectionLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
import com.superbiz.agent.harness.tool.store.RedisCanonicalInvocationStore;
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
import com.superbiz.agent.harness.application.DiagnosisOperation;
import com.superbiz.agent.harness.application.KnowledgeQueryOperation;
import com.superbiz.agent.harness.application.SystemChatOperation;
import com.superbiz.agent.harness.application.IntentRouting;
import com.superbiz.agent.hook.AgentLoggingHook;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.tool.LookupKnowledgeTool;
import com.superbiz.agent.agent.tool.QueryLogsTools;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.redis.core.RedisTemplate;
import javax.sql.DataSource;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Configuration
@EnableConfigurationProperties(ChatHarnessProperties.class)
public class HarnessChatConfiguration {
@Bean
public Clock harnessClock() {
return Clock.systemUTC();
}
@Bean(destroyMethod = "shutdown")
@Qualifier("chatWorkerExecutor")
public ThreadPoolExecutor chatWorkerExecutor(ChatHarnessProperties properties) {
properties.validate();
return boundedExecutor(properties.getWorkerCorePoolSize(), properties.getWorkerMaxPoolSize(),
properties.getWorkerQueueCapacity(), "chat-worker-");
}
@Bean(destroyMethod = "shutdown")
@Qualifier("harnessModelExecutor")
public ThreadPoolExecutor harnessModelExecutor(ChatHarnessProperties properties) {
properties.validate();
return boundedExecutor(properties.getModelCorePoolSize(), properties.getModelMaxPoolSize(),
properties.getModelQueueCapacity(), "harness-model-");
}
@Bean
public DiagnosisHarnessCore diagnosisHarnessCore(ChatHarnessProperties properties, Clock harnessClock) {
properties.validate();
return new DiagnosisHarnessCore(harnessClock, () -> UUID.randomUUID().toString(),
properties.getRunTimeout(),
new RunBudgetLimits(properties.getMaxModelCalls(), properties.getMaxToolCalls(),
properties.getMaxCallsPerTool(), properties.getMaxInputTokens(),
properties.getMaxOutputTokens(), properties.getMaxTotalTokens(), properties.getMaxRunBytes()),
HarnessRetryPolicies.strict());
}
@Bean
public HarnessRetryExecutor harnessRetryExecutor(DiagnosisHarnessCore core) {
return new HarnessRetryExecutor(core);
}
@Bean
public GuardModelCall guardModelCall(DiagnosisHarnessCore core, ChatModel chatModel,
@Qualifier("harnessModelExecutor") ThreadPoolExecutor executor) {
return new GuardModelCall(core, chatModel, executor);
}
@Bean
public CanonicalInvocationLimits canonicalInvocationLimits(ChatHarnessProperties properties) {
return new CanonicalInvocationLimits(properties.getCanonicalTtl(),
properties.getCanonicalMaxRecordBytes(), properties.getCanonicalMaxAgentResultBytes());
}
@Bean
public ToolCallKeyFactory toolCallKeyFactory(ChatHarnessProperties properties) {
return new ToolCallKeyFactory(properties.getToolKeyPrefix());
}
@Bean
public CanonicalInvocationStore canonicalInvocationStore(
RedisTemplate<String, Object> redisTemplate, ObjectMapper objectMapper,
CanonicalInvocationLimits limits) {
return new RedisCanonicalInvocationStore(redisTemplate, objectMapper, limits);
}
@Bean
public ToolBoundary toolBoundary(DiagnosisHarnessCore core, ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store, ObjectMapper objectMapper, Clock clock) {
return new ToolBoundary(core, keyFactory, store, objectMapper, clock);
}
@Bean
public ToolProjectionLimits toolProjectionLimits() {
return ToolProjectionLimits.defaults();
}
@Bean
public RagResultProjector ragResultProjector(ObjectMapper mapper, ToolProjectionLimits limits) {
return new RagResultProjector(mapper, limits);
}
@Bean
public QueryLogsResultProjector queryLogsResultProjector(ObjectMapper mapper, ToolProjectionLimits limits) {
return new QueryLogsResultProjector(mapper, limits);
}
@Bean
public RagToolAdapter ragToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
RagResultProjector projector, LookupKnowledgeTool legacy) {
return new RagToolAdapter(boundary, mapper, projector, legacy::lookupKnowledge);
}
@Bean
public QueryLogsToolAdapter queryLogsToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
QueryLogsResultProjector projector,
ObjectProvider<QueryLogsTools> legacy, Clock clock) {
return new QueryLogsToolAdapter(boundary, mapper, projector,
(region, topic, query, limit) -> {
QueryLogsTools tools = legacy.getIfAvailable();
if (tools == null) {
return "{\"success\":false,\"logs\":[],\"total\":0}";
}
return tools.queryLogs(region, topic, query, limit);
}, clock);
}
@Bean
public Map<String, DataSource> harnessMysqlDataSources(MysqlToolProperties properties) {
Map<String, DataSource> result = new java.util.LinkedHashMap<>();
for (Map.Entry<String, DataSourceProperties> entry : properties.getDataSources().entrySet()) {
DataSourceProperties source = entry.getValue();
if (source.getJdbcUrl() == null || source.getJdbcUrl().isBlank()) {
continue;
}
org.springframework.jdbc.datasource.DriverManagerDataSource dataSource =
new org.springframework.jdbc.datasource.DriverManagerDataSource();
dataSource.setUrl(source.getJdbcUrl());
dataSource.setUsername(source.getUsername());
dataSource.setPassword(source.getPassword());
result.put(entry.getKey(), dataSource);
}
return Map.copyOf(result);
}
@Bean
public Map<String, MysqlDataSourceDefinition> harnessMysqlDefinitions(MysqlToolProperties properties) {
return properties.definitions();
}
@Bean
public MysqlSqlValidator mysqlSqlValidator(Map<String, MysqlDataSourceDefinition> definitions) {
return new MysqlSqlValidator(definitions);
}
@Bean
public MysqlReadOnlyExecutor mysqlReadOnlyExecutor(
@Qualifier("harnessMysqlDataSources") Map<String, DataSource> dataSources, Clock clock) {
return new JdbcMysqlReadOnlyExecutor(dataSources, clock);
}
@Bean
public MysqlResultProjector mysqlResultProjector(ObjectMapper mapper) {
return new MysqlResultProjector(mapper);
}
@Bean
public MysqlToolAdapter mysqlToolAdapter(ToolBoundary boundary, ObjectMapper mapper,
MysqlSqlValidator validator, MysqlReadOnlyExecutor executor,
MysqlResultProjector projector) {
return new MysqlToolAdapter(boundary, mapper, validator, executor, projector);
}
@Bean
public HarnessEvidenceTools harnessEvidenceTools(RagToolAdapter rag, QueryLogsToolAdapter logs,
MysqlToolAdapter mysql) {
return HarnessEvidenceTools.fromAdapters(rag, logs, mysql);
}
@Bean
public DiagnosisAgentFactory diagnosisAgentFactory(ChatModel chatModel, DiagnosisHarnessCore core,
HarnessEvidenceTools tools, ObjectMapper mapper,
AgentStepRepository steps) {
return new DiagnosisAgentFactory(chatModel, core, tools, mapper,
List.of(new AgentLoggingHook(steps, DiagnosisAgentFactory.AGENT_NAME)));
}
@Bean
public DiagnosisAgentUseCase diagnosisAgentUseCase(DiagnosisHarnessCore core,
DiagnosisAgentFactory factory,
ObjectMapper mapper,
ChatHarnessProperties properties) {
return new DiagnosisAgentUseCase(core, factory, mapper, new DiagnosisAgentLimits(
properties.getDiagnosisMaxQueryBytes(), properties.getDiagnosisMaxPreviousTurnBytes(),
properties.getDiagnosisMaxInputBytes(), properties.getDiagnosisMaxDraftBytes()));
}
@Bean
public EvidenceGuard evidenceGuard(CanonicalInvocationStore store, ToolCallKeyFactory keyFactory,
ObjectMapper mapper) {
return new EvidenceGuard(store, keyFactory, mapper);
}
@Bean
public EvidenceRepair evidenceRepair(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
GuardModelCall modelCall, ObjectMapper mapper,
ChatHarnessProperties properties) {
return new EvidenceRepair(core, retries, modelCall, mapper,
new EvidenceRepairLimits(properties.getRepairMaxInputBytes(), properties.getRepairMaxOutputBytes(),
properties.getRepairTimeout()),
attempt -> { });
}
@Bean
public SemanticGuard semanticGuard(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
GuardModelCall modelCall, ObjectMapper mapper,
ChatHarnessProperties properties) {
return new SemanticGuard(core, retries, modelCall, mapper,
new SemanticGuardLimits(properties.getSemanticMaxInputBytes(), properties.getSemanticMaxOutputBytes(),
properties.getSemanticAttemptTimeout(), properties.getSemanticTotalTimeout()),
attempt -> { });
}
@Bean
public DiagnosisReleaseUseCase diagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair repair,
SemanticGuard semanticGuard) {
return new DiagnosisReleaseUseCase(evidenceGuard, repair, semanticGuard, new SafeFallbackFactory());
}
@Bean
public PublishedResultPolicy publishedResultPolicy() {
return new PublishedResultPolicy(PreviousTurnLimits.defaults());
}
@Bean
public IntentRouting intentRouter(DiagnosisHarnessCore core, HarnessRetryExecutor retries,
GuardModelCall modelCall, ObjectMapper mapper,
ChatHarnessProperties properties) {
return new IntentRouter(core, retries, modelCall, mapper,
new IntentRouterLimits(properties.getRouterMaxInputBytes(), properties.getRouterMaxOutputBytes(),
properties.getRouterAttemptTimeout(), properties.getRouterTotalTimeout()),
attempt -> { });
}
@Bean
public SystemChatOperation systemChatOperation(DiagnosisHarnessCore core, GuardModelCall modelCall,
ObjectMapper mapper, ChatHarnessProperties properties) {
return new SystemChatExecutor(core, modelCall,
new SingleTurnExecutorLimits(properties.getSystemMaxInputBytes(),
properties.getSystemMaxOutputBytes(), properties.getSystemTimeout()));
}
@Bean
public KnowledgeQueryOperation knowledgeQueryOperation(DiagnosisHarnessCore core,
HarnessEvidenceTools tools,
GuardModelCall modelCall,
ObjectMapper mapper,
ChatHarnessProperties properties) {
return new KnowledgeQueryExecutor(core, tools, modelCall, mapper, () -> UUID.randomUUID().toString(),
new KnowledgeQueryLimits(properties.getKnowledgeMaxModelInputBytes(),
properties.getKnowledgeMaxModelOutputBytes(), properties.getKnowledgeModelTimeout()));
}
@Bean
public DiagnosisOperation diagnosisOperation(DiagnosisAgentUseCase agent,
DiagnosisReleaseUseCase release,
PublishedResultPolicy policy) {
return new DiagnosisChatExecutor(agent, release, policy);
}
@Bean
public ChatApplicationUseCase chatApplicationUseCase(DiagnosisHarnessCore core,
ChatRunStore runStore,
IntentRouting router,
SystemChatOperation system,
KnowledgeQueryOperation knowledge,
DiagnosisOperation diagnosis,
ObjectMapper mapper) {
return new ChatApplicationUseCase(core, () -> UUID.randomUUID().toString(), runStore,
router, system, knowledge, diagnosis, mapper);
}
private static ThreadPoolExecutor boundedExecutor(int core, int max, int queue, String prefix) {
return new ThreadPoolExecutor(core, max, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queue), runnable -> {
Thread thread = new Thread(runnable);
thread.setName(prefix + thread.getId());
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy());
}
}
@@ -0,0 +1,124 @@
package com.superbiz.agent.controller;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.superbiz.agent.dto.AIOpsRequest;
import com.superbiz.agent.service.AiOpsService;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
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.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
/** Preserves the legacy AiOps SSE protocol independently from Chat. */
@RestController
@RequestMapping("/api")
public class AiOpsController {
private final AiOpsService aiOpsService;
private final ThreadPoolExecutor workerExecutor;
public AiOpsController(AiOpsService aiOpsService,
@Qualifier("chatWorkerExecutor") ThreadPoolExecutor workerExecutor) {
this.aiOpsService = aiOpsService;
this.workerExecutor = workerExecutor;
}
@PostMapping(value = "/ai_ops", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter aiOps(@RequestBody(required = false) AIOpsRequest request) {
SseEmitter emitter = new SseEmitter(600_000L);
String sessionId = aiOpsService.resolveSessionId(request);
String runId = aiOpsService.newRunId();
emitter.onTimeout(emitter::complete);
try {
workerExecutor.execute(() -> executeAiOps(request, sessionId, runId, emitter));
} catch (RejectedExecutionException rejected) {
emitter.completeWithError(rejected);
}
return emitter;
}
private void executeAiOps(AIOpsRequest request, String sessionId, String runId, SseEmitter emitter) {
try {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.metadata(sessionId, runId), MediaType.APPLICATION_JSON));
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("正在读取告警并拆解任务...\n"), MediaType.APPLICATION_JSON));
Optional<OverAllState> state = aiOpsService.executeAiOpsAnalysis(request, sessionId, runId);
if (state.isEmpty()) {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.error("多 Agent 编排未获取到有效结果"), MediaType.APPLICATION_JSON));
emitter.complete();
return;
}
Optional<String> report = aiOpsService.extractFinalReport(state.get());
if (report.isPresent()) {
aiOpsService.persistFinalReport(sessionId, runId, report.get(), request);
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content(report.get()), MediaType.APPLICATION_JSON));
} else {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("多 Agent 流程已完成,但未能生成最终报告。"), MediaType.APPLICATION_JSON));
}
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.done(), MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception exception) {
try {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.error("AI Ops 流程失败"), MediaType.APPLICATION_JSON));
} catch (IOException ignored) {
// Client has already gone away.
}
emitter.completeWithError(exception);
}
}
@Getter
@Setter
public static class SseMessage {
private String type;
private String data;
private String sessionId;
private String runId;
public static SseMessage content(String data) {
SseMessage message = new SseMessage();
message.type = "content";
message.data = data;
return message;
}
public static SseMessage metadata(String sessionId, String runId) {
SseMessage message = new SseMessage();
message.type = "metadata";
message.sessionId = sessionId;
message.runId = runId;
return message;
}
public static SseMessage error(String data) {
SseMessage message = new SseMessage();
message.type = "error";
message.data = data;
return message;
}
public static SseMessage done() {
SseMessage message = new SseMessage();
message.type = "done";
return message;
}
}
}
@@ -1,521 +1,90 @@
package com.superbiz.agent.controller;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.config.ChatHarnessProperties;
import com.superbiz.agent.controller.sse.ChatSseSession;
import com.superbiz.agent.harness.application.ChatApplicationException;
import com.superbiz.agent.harness.application.ChatApplicationRequest;
import com.superbiz.agent.harness.application.ChatApplicationResult;
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
import com.superbiz.agent.harness.application.ChatFailureCode;
import lombok.Getter;
import lombok.Setter;
import com.superbiz.agent.domain.model.SessionContext;
import com.superbiz.agent.dto.AIOpsRequest;
import com.superbiz.agent.dto.DiagnosisTraceResponse;
import com.superbiz.agent.service.AiOpsService;
import com.superbiz.agent.service.ChatService;
import com.superbiz.agent.service.DiagnosisTraceService;
import com.superbiz.agent.service.session.SessionManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
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.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 统一 API 控制器
* 适配前端接口需求
*/
/** HTTP and SSE protocol adapter for Chat. */
@RestController
@RequestMapping("/api")
public class ChatController {
private static final Logger logger = LoggerFactory.getLogger(ChatController.class);
private final ChatApplicationUseCase chatApplication;
private final ThreadPoolExecutor chatWorkerExecutor;
private final ChatHarnessProperties harnessProperties;
@Autowired
private AiOpsService aiOpsService;
@Autowired
private ChatService chatService;
public ChatController(ChatApplicationUseCase chatApplication,
@Qualifier("chatWorkerExecutor") ThreadPoolExecutor chatWorkerExecutor,
ChatHarnessProperties harnessProperties) {
this.chatApplication = chatApplication;
this.chatWorkerExecutor = chatWorkerExecutor;
this.harnessProperties = harnessProperties;
}
@Autowired
private DiagnosisTraceService diagnosisTraceService;
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> chat(@RequestBody ChatRequest request) {
if (request == null || request.getQuestion() == null || request.getQuestion().isBlank()) {
return ResponseEntity.badRequest().build();
}
@Autowired
private SessionManager sessionManager;
SseEmitter emitter = new SseEmitter(harnessProperties.getSseTimeout().toMillis());
ChatSseSession session = new ChatSseSession(emitter);
emitter.onTimeout(session::disconnect);
emitter.onError(ignored -> session.disconnect());
emitter.onCompletion(session::disconnect);
@Autowired(required = false)
private ToolCallbackProvider tools;
private final ExecutorService executor = Executors.newCachedThreadPool();
// 最大历史消息窗口大小(成对计算:用户消息+AI回复=1对)
private static final int MAX_WINDOW_SIZE = 6;
@Value("${session.ttl-seconds:3600}")
private long sessionTtlSeconds;
/**
* 普通对话接口(支持工具调用)
* 与 /chat_react 逻辑一致,但直接返回完整结果而非流式输出
*/
@PostMapping("/chat")
public ResponseEntity<ApiResponse<ChatResponse>> chat(@RequestBody ChatRequest request) {
try {
logger.info("收到对话请求 - SessionId: {}, Question: {}", request.getId(), request.getQuestion());
// 参数校验
if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
logger.warn("问题内容为空");
return ResponseEntity.ok(ApiResponse.success(ChatResponse.error("问题内容不能为空")));
}
// 获取或创建会话
SessionContext session = getOrCreateSession(request.getId());
// 获取历史消息
List<Map<String, String>> history = session.getMessageHistorySnapshot();
logger.info("会话历史消息对数: {}", history.size() / 2);
// 获取注入的 ChatModel
ChatModel chatModel = chatService.getChatModel();
// 记录可用工具
chatService.logAvailableTools();
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
// 根据问题复杂度自动选择单 Agent 或多 Agent
logger.info("开始 ReactAgent 对话(支持自动工具调用)");
ChatService.ChatResult result = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
request.getQuestion(), history, session.getSessionId());
String fullAnswer = result.answer();
// 更新会话历史
session.addChatMessagePair(request.getQuestion(), fullAnswer, MAX_WINDOW_SIZE);
sessionManager.updateSession(session);
chatService.syncChatSessionMetadata(session.getSessionId(), session.getMessagePairCount());
logger.info("已更新会话历史 - SessionId: {}, 当前消息对数: {}",
session.getSessionId(), session.getMessagePairCount());
return ResponseEntity.ok(ApiResponse.success(ChatResponse.success(fullAnswer, result.sessionId(), result.runId())));
} catch (Exception e) {
logger.error("对话失败", e);
return ResponseEntity.ok(ApiResponse.success(ChatResponse.error(e.getMessage())));
chatWorkerExecutor.execute(() -> executeChat(request, session));
} catch (RejectedExecutionException rejected) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
}
return ResponseEntity.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(emitter);
}
/**
* 清空会话历史
*/
@PostMapping("/chat/clear")
public ResponseEntity<ApiResponse<String>> clearChatHistory(@RequestBody ClearRequest request) {
private void executeChat(ChatRequest request, ChatSseSession session) {
try {
logger.info("收到清空会话历史请求 - SessionId: {}", request.getId());
if (request.getId() == null || request.getId().isEmpty()) {
return ResponseEntity.ok(ApiResponse.error("会话ID不能为空"));
}
Optional<SessionContext> session = sessionManager.getSession(request.getId());
if (session.isPresent()) {
SessionContext context = session.get();
context.clearMessageHistory();
sessionManager.updateSession(context);
return ResponseEntity.ok(ApiResponse.success("会话历史已清空"));
} else {
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
}
} catch (Exception e) {
logger.error("清空会话历史失败", e);
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
ChatApplicationResult result = chatApplication.execute(
new ChatApplicationRequest(request.getQuestion(), request.getId()), session);
session.complete(result);
} catch (ChatApplicationException exception) {
session.fail(exception);
} catch (RuntimeException exception) {
session.fail(ChatFailureCode.INTERNAL_FAILURE,
"当前暂时无法处理该请求,请稍后重试");
}
}
/**
* 对话接口(SSE 流式模式)
* 与 /chat 使用同一条 ChatService 策略链路,区别仅在于通过 SSE 分块返回最终答案。
*/
@PostMapping(value = "/chat_stream", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chatStream(@RequestBody ChatRequest request) {
SseEmitter emitter = new SseEmitter(300000L); // 5分钟超时
// 参数校验
if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) {
logger.warn("问题内容为空");
try {
emitter.send(SseEmitter.event().name("message").data(SseMessage.error("问题内容不能为空"), MediaType.APPLICATION_JSON));
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
return emitter;
}
executor.execute(() -> {
try {
logger.info("收到 ReactAgent 对话请求 - SessionId: {}, Question: {}", request.getId(), request.getQuestion());
// 获取或创建会话
SessionContext session = getOrCreateSession(request.getId());
// 获取历史消息
List<Map<String, String>> history = session.getMessageHistorySnapshot();
logger.info("ReactAgent 会话历史消息对数: {}", history.size() / 2);
// 获取注入的 ChatModel
ChatModel chatModel = chatService.getChatModel();
// 记录可用工具
chatService.logAvailableTools();
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
logger.info("开始统一 ChatService 对话(SSE 分块返回)");
ChatService.ChatResult result = chatService.executeChatWithStrategy(chatModel, toolCallbacks,
request.getQuestion(), history, session.getSessionId());
String fullAnswer = result.answer() == null ? "" : result.answer();
logger.info("统一 ChatService 对话完成 - SessionId: {}, 答案长度: {}",
result.sessionId(), fullAnswer.length());
session.addChatMessagePair(request.getQuestion(), fullAnswer, MAX_WINDOW_SIZE);
sessionManager.updateSession(session);
logger.info("已更新会话历史 - SessionId: {}, 当前消息对数: {}",
session.getSessionId(), session.getMessagePairCount());
sendContentChunks(emitter, fullAnswer);
emitter.send(SseEmitter.event()
.name("message")
.data(SseMessage.done(), MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception e) {
logger.error("ReactAgent 对话初始化失败", e);
try {
emitter.send(SseEmitter.event()
.name("message")
.data(SseMessage.error(e.getMessage()), MediaType.APPLICATION_JSON));
} catch (IOException ex) {
logger.error("发送错误消息失败", ex);
}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* AI 智能运维接口(SSE 流式模式)- 自动分析告警并生成运维报告
* 无需用户输入,自动执行告警分析流程
*/
@PostMapping(value = "/ai_ops", produces = "text/event-stream;charset=UTF-8")
public SseEmitter aiOps(@RequestBody(required = false) AIOpsRequest request) {
SseEmitter emitter = new SseEmitter(600000L); // 10分钟超时(告警分析可能较慢)
String sessionId = aiOpsService.resolveSessionId(request);
String runId = aiOpsService.newRunId();
executor.execute(() -> {
try {
logger.info("收到 AI 智能运维请求 - SessionId: {}, RunId: {}, 启动多 Agent 协作流程", sessionId, runId);
ChatModel chatModel = chatService.getChatModel();
ToolCallback[] toolCallbacks = tools != null ? tools.getToolCallbacks() : new ToolCallback[0];
emitter.send(SseEmitter.event().name("message").data(SseMessage.metadata(sessionId, runId), MediaType.APPLICATION_JSON));
emitter.send(SseEmitter.event().name("message").data(SseMessage.content("正在读取告警并拆解任务...\n")));
// 调用 AiOpsService 执行分析流程
Optional<OverAllState> overAllStateOptional = aiOpsService.executeAiOpsAnalysis(chatModel, toolCallbacks, request, sessionId, runId);
if (overAllStateOptional.isEmpty()) {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.error("多 Agent 编排未获取到有效结果"), MediaType.APPLICATION_JSON));
emitter.complete();
return;
}
OverAllState state = overAllStateOptional.get();
logger.info("AI Ops 编排完成,开始提取最终报告...");
// 提取最终报告
Optional<String> finalReportOptional = aiOpsService.extractFinalReport(state);
// 输出最终报告
if (finalReportOptional.isPresent()) {
String finalReportText = finalReportOptional.get();
logger.info("提取到 Planner 最终报告,长度: {}", finalReportText.length());
aiOpsService.persistFinalReport(sessionId, runId, finalReportText, request);
// 发送分隔线
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("\n\n" + "=".repeat(60) + "\n"), MediaType.APPLICATION_JSON));
// 发送完整的告警分析报告
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("📋 **告警分析报告**\n\n"), MediaType.APPLICATION_JSON));
int chunkSize = 50;
for (int i = 0; i < finalReportText.length(); i += chunkSize) {
int end = Math.min(i + chunkSize, finalReportText.length());
String chunk = finalReportText.substring(i, end);
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content(chunk), MediaType.APPLICATION_JSON));
}
// 发送结束分隔线
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("\n" + "=".repeat(60) + "\n\n"), MediaType.APPLICATION_JSON));
logger.info("最终报告已完整输出");
} else {
logger.warn("未能提取到 Planner 最终报告");
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.content("⚠️ 多 Agent 流程已完成,但未能生成最终报告。"), MediaType.APPLICATION_JSON));
}
emitter.send(SseEmitter.event().name("message").data(SseMessage.done(), MediaType.APPLICATION_JSON));
emitter.complete();
logger.info("AI Ops 多 Agent 编排完成");
} catch (Exception e) {
logger.error("AI Ops 多 Agent 协作失败", e);
try {
emitter.send(SseEmitter.event().name("message")
.data(SseMessage.error("AI Ops 流程失败: " + e.getMessage()), MediaType.APPLICATION_JSON));
} catch (IOException ex) {
logger.error("发送错误消息失败", ex);
}
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 获取会话信息
*/
@GetMapping("/chat/session/{sessionId}")
public ResponseEntity<ApiResponse<SessionInfoResponse>> getSessionInfo(@PathVariable String sessionId) {
try {
logger.info("收到获取会话信息请求 - SessionId: {}", sessionId);
Optional<SessionContext> session = sessionManager.getSession(sessionId);
if (session.isPresent()) {
SessionContext context = session.get();
SessionInfoResponse response = new SessionInfoResponse();
response.setSessionId(sessionId);
response.setMessagePairCount(context.getMessagePairCount());
response.setCreateTime(toEpochMillis(context.getCreatedAt()));
return ResponseEntity.ok(ApiResponse.success(response));
} else {
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
}
} catch (Exception e) {
logger.error("获取会话信息失败", e);
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
@GetMapping("/chat/session/{sessionId}/runs")
public ResponseEntity<ApiResponse<List<DiagnosisTraceResponse.RunSummary>>> listSessionRuns(
@PathVariable String sessionId) {
return ResponseEntity.ok(ApiResponse.success(diagnosisTraceService.listRunSummaries(sessionId)));
}
// ==================== 辅助方法 ====================
private SessionContext getOrCreateSession(String sessionId) {
String resolvedSessionId = (sessionId == null || sessionId.isEmpty())
? UUID.randomUUID().toString()
: sessionId;
return sessionManager.getSession(resolvedSessionId)
.orElseGet(() -> {
SessionContext context = SessionContext.builder()
.sessionId(resolvedSessionId)
.status("ACTIVE")
.ttl(sessionTtlSeconds)
.build();
sessionManager.createSession(context, sessionTtlSeconds);
return context;
});
}
private long toEpochMillis(LocalDateTime time) {
if (time == null) {
return 0L;
}
return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
private void sendContentChunks(SseEmitter emitter, String content) throws IOException {
if (content == null || content.isEmpty()) {
return;
}
int chunkSize = 80;
for (int i = 0; i < content.length(); i += chunkSize) {
int end = Math.min(i + chunkSize, content.length());
emitter.send(SseEmitter.event()
.name("message")
.data(SseMessage.content(content.substring(i, end)), MediaType.APPLICATION_JSON));
}
}
/**
* 聊天请求
*/
@Setter
@Getter
@Setter
public static class ChatRequest {
@com.fasterxml.jackson.annotation.JsonProperty(value = "Id")
@com.fasterxml.jackson.annotation.JsonAlias({"id", "ID"})
private String Id;
@com.fasterxml.jackson.annotation.JsonProperty(value = "Question")
@com.fasterxml.jackson.annotation.JsonAlias({"question", "QUESTION"})
private String Question;
@JsonProperty("Id")
@JsonAlias({"id", "ID"})
private String id;
@JsonProperty("Question")
@JsonAlias({"question", "QUESTION"})
private String question;
}
/**
* 清空会话请求
*/
@Setter
@Getter
public static class ClearRequest {
@com.fasterxml.jackson.annotation.JsonProperty(value = "Id")
@com.fasterxml.jackson.annotation.JsonAlias({"id", "ID"})
private String Id;
}
// ==================== 内部类 ====================
/**
* 会话信息响应
*/
@Setter
@Getter
public static class SessionInfoResponse {
private String sessionId;
private int messagePairCount;
private long createTime;
}
/**
* 统一聊天响应格式
* 适用于所有普通返回模式的对话接口
*/
@Setter
@Getter
public static class ChatResponse {
private boolean success;
private String answer;
private String errorMessage;
private String sessionId;
private String runId;
public static ChatResponse success(String answer, String sessionId, String runId) {
ChatResponse response = new ChatResponse();
response.setSuccess(true);
response.setAnswer(answer);
response.setSessionId(sessionId);
response.setRunId(runId);
return response;
}
public static ChatResponse error(String errorMessage) {
ChatResponse response = new ChatResponse();
response.setSuccess(false);
response.setErrorMessage(errorMessage);
return response;
}
}
/**
* 统一 SSE 流式消息格式
* 适用于所有 SSE 流式返回模式的对话接口
*/
@Setter
@Getter
public static class SseMessage {
private String type; // metadata: 元数据, content: 内容块, error: 错误, done: 完成
private String data;
private String sessionId;
private String runId;
public static SseMessage content(String data) {
SseMessage message = new SseMessage();
message.setType("content");
message.setData(data);
return message;
}
public static SseMessage session(String sessionId) {
SseMessage message = new SseMessage();
message.setType("session");
message.setData(sessionId);
return message;
}
public static SseMessage metadata(String sessionId, String runId) {
SseMessage message = new SseMessage();
message.setType("metadata");
message.setSessionId(sessionId);
message.setRunId(runId);
return message;
}
public static SseMessage error(String errorMessage) {
SseMessage message = new SseMessage();
message.setType("error");
message.setData(errorMessage);
return message;
}
public static SseMessage done() {
SseMessage message = new SseMessage();
message.setType("done");
message.setData(null);
return message;
}
}
@Getter
@Setter
public static class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(200);
response.setMessage("success");
response.setData(data);
return response;
}
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.setCode(500);
response.setMessage(message);
return response;
}
}
}
@@ -0,0 +1,122 @@
package com.superbiz.agent.controller;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.dto.DiagnosisTraceResponse;
import com.superbiz.agent.service.DiagnosisTraceService;
import com.superbiz.agent.service.session.SessionManager;
import lombok.Getter;
import lombok.Setter;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
/** Legacy Chat session management endpoints, isolated from the Chat execution adapter. */
@RestController
@RequestMapping("/api/chat")
public class ChatSessionController {
private final DiagnosisTraceService diagnosisTraceService;
private final SessionManager sessionManager;
public ChatSessionController(DiagnosisTraceService diagnosisTraceService, SessionManager sessionManager) {
this.diagnosisTraceService = diagnosisTraceService;
this.sessionManager = sessionManager;
}
@PostMapping("/clear")
public ResponseEntity<ApiResponse<String>> clearChatHistory(@RequestBody ClearRequest request) {
if (request == null || request.getId() == null || request.getId().isBlank()) {
return ResponseEntity.ok(ApiResponse.error("会话ID不能为空"));
}
try {
Optional<com.superbiz.agent.domain.model.SessionContext> session =
sessionManager.getSession(request.getId());
if (session.isEmpty()) {
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
}
var context = session.get();
context.clearMessageHistory();
sessionManager.updateSession(context);
return ResponseEntity.ok(ApiResponse.success("会话历史已清空"));
} catch (RuntimeException exception) {
return ResponseEntity.ok(ApiResponse.error("会话历史清理失败"));
}
}
@GetMapping("/session/{sessionId}")
public ResponseEntity<ApiResponse<SessionInfoResponse>> getSessionInfo(@PathVariable String sessionId) {
try {
Optional<com.superbiz.agent.domain.model.SessionContext> session = sessionManager.getSession(sessionId);
if (session.isEmpty()) {
return ResponseEntity.ok(ApiResponse.error("会话不存在"));
}
var context = session.get();
SessionInfoResponse response = new SessionInfoResponse();
response.setSessionId(sessionId);
response.setMessagePairCount(context.getMessagePairCount());
response.setCreateTime(toEpochMillis(context.getCreatedAt()));
return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException exception) {
return ResponseEntity.ok(ApiResponse.error("会话读取失败"));
}
}
@GetMapping("/session/{sessionId}/runs")
public ResponseEntity<ApiResponse<List<DiagnosisTraceResponse.RunSummary>>> listSessionRuns(
@PathVariable String sessionId) {
return ResponseEntity.ok(ApiResponse.success(diagnosisTraceService.listRunSummaries(sessionId)));
}
private long toEpochMillis(LocalDateTime time) {
return time == null ? 0L : time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
@Getter
@Setter
public static class ClearRequest {
@JsonProperty("Id")
@JsonAlias({"id", "ID"})
private String id;
}
@Getter
@Setter
public static class SessionInfoResponse {
private String sessionId;
private int messagePairCount;
private long createTime;
}
@Getter
@Setter
public static class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.code = 200;
response.message = "success";
response.data = data;
return response;
}
public static <T> ApiResponse<T> error(String message) {
ApiResponse<T> response = new ApiResponse<>();
response.code = 500;
response.message = message;
return response;
}
}
}
@@ -0,0 +1,8 @@
package com.superbiz.agent.controller.sse;
final class ChatSseDisconnectedException extends RuntimeException {
ChatSseDisconnectedException(Throwable cause) {
super("Chat SSE connection is no longer writable", cause);
}
}
@@ -0,0 +1,68 @@
package com.superbiz.agent.controller.sse;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.application.ChatApplicationContent;
import com.superbiz.agent.harness.application.ChatApplicationStatus;
import com.superbiz.agent.harness.application.ChatContentType;
import com.superbiz.agent.harness.application.ChatFailureCode;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import java.util.Objects;
public interface ChatSseEvent {
record Metadata(
@JsonProperty("session_id") String sessionId,
@JsonProperty("run_id") String runId) implements ChatSseEvent {
public Metadata {
requireText(sessionId, "sessionId");
requireText(runId, "runId");
}
}
record Status(String code, String message) implements ChatSseEvent {
public Status {
requireText(code, "code");
requireText(message, "message");
}
public static Status from(ChatApplicationStatus status) {
Objects.requireNonNull(status, "status must not be null");
return new Status(status.name(), status.message());
}
}
record Content(
@JsonProperty("content_type") ChatContentType contentType,
@JsonProperty("payload") ChatApplicationContent payload) implements ChatSseEvent {
public Content {
Objects.requireNonNull(contentType, "contentType must not be null");
Objects.requireNonNull(payload, "payload must not be null");
if (contentType != payload.contentType()) {
throw new IllegalArgumentException("contentType does not match payload");
}
}
}
record Failure(ChatFailureCode code, String message) implements ChatSseEvent {
public Failure {
Objects.requireNonNull(code, "code must not be null");
requireText(message, "message");
}
}
record Done(ReleaseOutcome outcome) implements ChatSseEvent {
public Done {
Objects.requireNonNull(outcome, "outcome must not be null");
if (outcome == ReleaseOutcome.CANCELLED) {
throw new IllegalArgumentException("CANCELLED is not a public SSE outcome");
}
}
}
private static void requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
}
}
@@ -0,0 +1,133 @@
package com.superbiz.agent.controller.sse;
import com.superbiz.agent.harness.application.ChatApplicationException;
import com.superbiz.agent.harness.application.ChatApplicationObserver;
import com.superbiz.agent.harness.application.ChatApplicationResult;
import com.superbiz.agent.harness.application.ChatApplicationStatus;
import com.superbiz.agent.harness.application.ChatFailureCode;
import com.superbiz.agent.harness.application.ChatRunControl;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Objects;
public final class ChatSseSession implements ChatApplicationObserver {
private enum State {
NEW,
OPEN,
TERMINAL,
DISCONNECTED
}
private final ChatSseSink sink;
private State state = State.NEW;
private ChatRunControl runControl;
public ChatSseSession(SseEmitter emitter) {
this(new SseEmitterChatSink(emitter));
}
ChatSseSession(ChatSseSink sink) {
this.sink = Objects.requireNonNull(sink, "sink must not be null");
}
@Override
public synchronized void onStarted(ChatRunControl control) {
Objects.requireNonNull(control, "runControl must not be null");
if (runControl != null) {
throw new IllegalStateException("Run control was already published");
}
runControl = control;
if (state == State.DISCONNECTED) {
control.cancelClientDisconnect();
return;
}
requireState(State.NEW);
send("metadata", new ChatSseEvent.Metadata(control.sessionId(), control.runId()));
state = State.OPEN;
}
@Override
public synchronized void onStatus(ChatApplicationStatus status) {
if (state == State.DISCONNECTED) {
return;
}
requireState(State.OPEN);
send("status", ChatSseEvent.Status.from(status));
}
public synchronized boolean complete(ChatApplicationResult result) {
Objects.requireNonNull(result, "result must not be null");
if (state == State.DISCONNECTED || state == State.TERMINAL) {
return false;
}
requireState(State.OPEN);
requireMatchingRun(result);
send("content", new ChatSseEvent.Content(result.contentType(), result.content()));
send("done", new ChatSseEvent.Done(result.outcome()));
state = State.TERMINAL;
sink.complete();
return true;
}
public synchronized boolean fail(ChatApplicationException failure) {
Objects.requireNonNull(failure, "failure must not be null");
return fail(failure.code(), failure.getMessage());
}
public synchronized boolean fail(ChatFailureCode code, String message) {
if (state == State.DISCONNECTED || state == State.TERMINAL) {
return false;
}
if (state == State.NEW) {
state = State.TERMINAL;
sink.complete();
return false;
}
requireState(State.OPEN);
send("failure", new ChatSseEvent.Failure(code, message));
send("done", new ChatSseEvent.Done(ReleaseOutcome.FAILED));
state = State.TERMINAL;
sink.complete();
return true;
}
public synchronized boolean disconnect() {
if (state == State.DISCONNECTED || state == State.TERMINAL) {
return false;
}
state = State.DISCONNECTED;
return runControl != null && runControl.cancelClientDisconnect();
}
public synchronized boolean terminal() {
return state == State.TERMINAL;
}
private void requireMatchingRun(ChatApplicationResult result) {
if (runControl == null
|| !runControl.sessionId().equals(result.sessionId())
|| !runControl.runId().equals(result.runId())) {
disconnect();
throw new IllegalStateException("Application result does not match SSE Run metadata");
}
}
private void send(String name, ChatSseEvent payload) {
try {
sink.send(name, payload);
} catch (IOException | RuntimeException exception) {
disconnect();
throw new ChatSseDisconnectedException(exception);
}
}
private void requireState(State expected) {
if (state != expected) {
throw new IllegalStateException(
"Invalid Chat SSE state: expected " + expected + " but was " + state);
}
}
}
@@ -0,0 +1,10 @@
package com.superbiz.agent.controller.sse;
import java.io.IOException;
interface ChatSseSink {
void send(String name, Object payload) throws IOException;
void complete();
}
@@ -0,0 +1,28 @@
package com.superbiz.agent.controller.sse;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Objects;
final class SseEmitterChatSink implements ChatSseSink {
private final SseEmitter emitter;
SseEmitterChatSink(SseEmitter emitter) {
this.emitter = Objects.requireNonNull(emitter, "emitter must not be null");
}
@Override
public void send(String name, Object payload) throws IOException {
emitter.send(SseEmitter.event()
.name(name)
.data(payload, MediaType.APPLICATION_JSON));
}
@Override
public void complete() {
emitter.complete();
}
}
@@ -32,7 +32,6 @@ public class JpaChatRunStore implements ChatRunStore {
private final ObjectReader publishedReader;
private final PublishedResultPolicy publishedPolicy;
@Autowired
public JpaChatRunStore(ChatSessionRepository chatSessions,
DiagnosisRunRepository runs,
ObjectMapper objectMapper) {
@@ -40,6 +39,7 @@ public class JpaChatRunStore implements ChatRunStore {
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
}
@Autowired
public JpaChatRunStore(ChatSessionRepository chatSessions,
DiagnosisRunRepository runs,
ObjectMapper objectMapper,
@@ -28,6 +28,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.superbiz.agent.config.AiOpsPromptProperties;
@@ -47,6 +48,12 @@ public class AiOpsService {
private static final Logger logger = LoggerFactory.getLogger(AiOpsService.class);
@Autowired
private ChatModel chatModel;
@Autowired(required = false)
private ToolCallbackProvider toolCallbackProvider;
@Autowired
private DateTimeTools dateTimeTools;
@@ -157,6 +164,15 @@ public class AiOpsService {
}
}
/** Internal dependency-owning overload used by the protocol-only Controller. */
public Optional<OverAllState> executeAiOpsAnalysis(AIOpsRequest request,
String sessionId,
String runId) throws GraphRunnerException {
ToolCallback[] callbacks = toolCallbackProvider == null
? new ToolCallback[0] : toolCallbackProvider.getToolCallbacks();
return executeAiOpsAnalysis(chatModel, callbacks, request, sessionId, runId);
}
/**
* 从多 Agent 执行状态中提取最终报告文本。
*