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 执行状态中提取最终报告文本。
*
+10
View File
@@ -204,5 +204,15 @@ logging:
# Agent-facing MySQL Tool uses independent logical datasources only.
# Production entries are supplied by a dedicated profile and Secret injection.
harness:
chat:
worker-core-pool-size: 2
worker-max-pool-size: 8
worker-queue-capacity: 64
model-core-pool-size: 4
model-max-pool-size: 8
model-queue-capacity: 32
run-timeout: 5m
sse-timeout: 5m
canonical-ttl: 2h
mysql-tools:
data-sources: {}
+119 -294
View File
@@ -2,7 +2,6 @@
class SuperBizAgentApp {
constructor() {
this.apiBaseUrl = 'http://localhost:9900/api';
this.currentMode = 'quick'; // 'quick' 或 'stream'
this.sessionId = this.generateSessionId();
this.isStreaming = false;
this.currentChatHistory = []; // 当前对话的消息历史
@@ -108,9 +107,6 @@ class SuperBizAgentApp {
this.toolsBtn = document.getElementById('toolsBtn');
this.toolsMenu = document.getElementById('toolsMenu');
this.uploadFileItem = document.getElementById('uploadFileItem');
this.modeSelectorBtn = document.getElementById('modeSelectorBtn');
this.modeDropdown = document.getElementById('modeDropdown');
this.currentModeText = document.getElementById('currentModeText');
this.fileInput = document.getElementById('fileInput');
// 聊天区域元素
@@ -137,32 +133,6 @@ class SuperBizAgentApp {
this.aiOpsSidebarBtn.addEventListener('click', () => this.triggerAIOps());
}
// 模式选择下拉菜单
if (this.modeSelectorBtn) {
this.modeSelectorBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleModeDropdown();
});
}
// 下拉菜单项点击
const dropdownItems = document.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const mode = item.getAttribute('data-mode');
this.selectMode(mode);
this.closeModeDropdown();
});
});
// 点击外部关闭下拉菜单
document.addEventListener('click', (e) => {
if (!this.modeSelectorBtn.contains(e.target) &&
!this.modeDropdown.contains(e.target)) {
this.closeModeDropdown();
}
});
// 发送消息
if (this.sendButton) {
this.sendButton.addEventListener('click', () => this.sendMessage());
@@ -271,8 +241,6 @@ class SuperBizAgentApp {
this.sessionId = this.generateSessionId();
this.updateTraceWorkbenchLink();
// 重置模式为快速
this.currentMode = 'quick';
this.updateUI();
// 重新设置居中样式(确保对话框居中显示)
@@ -552,8 +520,10 @@ class SuperBizAgentApp {
return;
}
const payload = sseMessage.data && typeof sseMessage.data === 'object' ? sseMessage.data : {};
const sessionId = sseMessage.sessionId || payload.sessionId;
const runId = sseMessage.runId || payload.runId;
const sessionId = sseMessage.session_id || sseMessage.sessionId
|| payload.session_id || payload.sessionId;
const runId = sseMessage.run_id || sseMessage.runId
|| payload.run_id || payload.runId;
if (!sessionId) {
return;
}
@@ -562,66 +532,8 @@ class SuperBizAgentApp {
this.rememberTraceTarget(sessionId, runId || null);
}
// 切换模式下拉菜单
toggleModeDropdown() {
if (this.modeSelectorBtn && this.modeDropdown) {
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
if (wrapper) {
wrapper.classList.toggle('active');
}
}
}
// 关闭模式下拉菜单
closeModeDropdown() {
if (this.modeSelectorBtn && this.modeDropdown) {
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
if (wrapper) {
wrapper.classList.remove('active');
}
}
}
// 选择模式
selectMode(mode) {
if (this.isStreaming) {
this.showNotification('请等待当前对话完成后再切换模式', 'warning');
return;
}
this.currentMode = mode;
this.updateUI();
const modeNames = {
'quick': '快速',
'stream': '流式'
};
this.showNotification(`已切换到${modeNames[mode]}模式`, 'info');
}
// 更新UI
updateUI() {
// 更新模式选择器显示
if (this.currentModeText) {
const modeNames = {
'quick': '快速',
'stream': '流式'
};
this.currentModeText.textContent = modeNames[this.currentMode] || '快速';
}
// 更新下拉菜单选中状态
const dropdownItems = document.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
const mode = item.getAttribute('data-mode');
if (mode === this.currentMode) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// 更新发送按钮状态
if (this.sendButton) {
this.sendButton.disabled = this.isStreaming;
@@ -669,11 +581,7 @@ class SuperBizAgentApp {
this.updateUI();
try {
if (this.currentMode === 'quick') {
await this.sendQuickMessage(message);
} else if (this.currentMode === 'stream') {
await this.sendStreamMessage(message);
}
await this.sendChatMessage(message);
} catch (error) {
console.error('发送消息失败:', error);
this.addMessage('assistant', '抱歉,发送消息时出现错误:' + error.message);
@@ -689,214 +597,131 @@ class SuperBizAgentApp {
}
}
// 发送快速消息(普通对话)
async sendQuickMessage(message) {
// 添加等待提示消息
const loadingMessage = this.addLoadingMessage('正在思考...');
try {
const response = await fetch(`${this.apiBaseUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
Id: this.sessionId,
Question: message
})
});
async sendChatMessage(message) {
const loadingMessage = this.addLoadingMessage('正在处理...');
const assistantMessageElement = this.addMessage('assistant', '', true);
const response = await fetch(`${this.apiBaseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: this.sessionId, Question: message })
});
if (!response.ok || !response.body) {
throw new Error(`HTTP错误: ${response.status}`);
}
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
const state = { metadata: false, content: false, failure: false, done: false };
let fullResponse = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const renderContent = (event) => {
if (state.content || state.failure || !state.metadata
|| !event.content_type || !event.payload) {
throw new Error('非法 content 事件');
}
state.content = true;
fullResponse = this.renderChatPayload(event.content_type, event.payload);
const contentElement = assistantMessageElement.querySelector('.message-content');
contentElement.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(contentElement);
this.scrollToBottom();
};
const data = await response.json();
console.log('[sendQuickMessage] 响应数据:', JSON.stringify(data));
// 移除等待提示消息
if (loadingMessage && loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
const handleEvent = (frame) => {
const lines = frame.split(/\r?\n/);
const eventName = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
const dataLines = lines.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trim());
if (!eventName || dataLines.length === 0) {
throw new Error('非法 SSE frame');
}
// 统一响应格式:检查 data.code 或 data.message 判断请求是否成功
if (data.code === 200 || data.message === 'success') {
// data.data 是 ChatResponse 对象
const chatResponse = data.data;
if (chatResponse && chatResponse.success) {
// 保存后端返回的 sessionId/runId,用于 feedback 提交和 Trace 精确定位
if (chatResponse.sessionId) {
this.lastSessionId = chatResponse.sessionId;
this.lastRunId = chatResponse.runId || '';
this.rememberTraceTarget(chatResponse.sessionId, chatResponse.runId || null);
const event = JSON.parse(dataLines.join('\n'));
switch (eventName) {
case 'metadata':
if (state.metadata || state.content || state.failure || state.done
|| !event.session_id || !event.run_id) {
throw new Error('非法 metadata 事件');
}
// 成功:添加实际响应消息(即使 answer 为空也显示)
const answer = chatResponse.answer || '(无回复内容)';
this.addMessage('assistant', answer);
} else if (chatResponse && chatResponse.errorMessage) {
// 业务错误
throw new Error(chatResponse.errorMessage);
} else {
// 兜底:尝试显示任何可用内容
const fallbackAnswer = chatResponse?.answer || chatResponse?.errorMessage || '服务返回了空内容';
this.addMessage('assistant', fallbackAnswer);
}
} else {
// HTTP 成功但业务失败
throw new Error(data.message || '请求失败');
state.metadata = true;
this.rememberRunMetadata(event);
break;
case 'status':
if (!state.metadata || state.content || state.failure || state.done
|| !event.code || !event.message) {
throw new Error('非法 status 事件');
}
const statusText = loadingMessage?.querySelector('.loading-message-content span');
if (statusText) statusText.textContent = event.message;
break;
case 'content':
renderContent(event);
break;
case 'failure':
if (state.failure || state.content || state.done || !state.metadata
|| !event.code || !event.message) {
throw new Error('非法 failure 事件');
}
state.failure = true;
fullResponse = event.message;
assistantMessageElement.querySelector('.message-content').textContent = event.message;
break;
case 'done':
if (state.done || !state.metadata || (!state.content && !state.failure)
|| !['SUCCESS', 'FALLBACK', 'FAILED'].includes(event.outcome)) {
throw new Error('非法 done 事件');
}
state.done = true;
if (event.outcome === 'FAILED') {
if (!state.failure) throw new Error('FAILED 缺少 failure');
} else if (!state.content) {
throw new Error('成功终态缺少 content');
}
break;
default:
throw new Error(`未知 SSE 事件: ${eventName}`);
}
} catch (error) {
// 出错时也要移除等待提示消息
if (loadingMessage && loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || '';
frames.filter(Boolean).forEach(handleEvent);
}
throw error;
buffer += decoder.decode();
if (buffer.trim()) handleEvent(buffer);
if (!state.done) throw new Error('SSE 缺少 done 终态');
this.handleStreamComplete(assistantMessageElement, fullResponse);
} finally {
reader.releaseLock();
if (loadingMessage && loadingMessage.parentNode) loadingMessage.parentNode.removeChild(loadingMessage);
}
}
// 发送流式消息
async sendStreamMessage(message) {
try {
const response = await fetch(`${this.apiBaseUrl}/chat_stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
Id: this.sessionId,
Question: message
})
});
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
// 创建助手消息元素
const assistantMessageElement = this.addMessage('assistant', '', true);
let fullResponse = '';
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// 流结束,使用统一的处理方法
this.handleStreamComplete(assistantMessageElement, fullResponse);
break;
}
// 解码数据并添加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行分割处理
const lines = buffer.split('\n');
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
console.log('[SSE调试] 收到行:', line);
// 解析SSE格式
if (line.startsWith('id:')) {
console.log('[SSE调试] 解析到ID');
continue;
} else if (line.startsWith('event:')) {
// 兼容 "event:message" 和 "event: message" 两种格式
currentEvent = line.substring(6).trim();
console.log('[SSE调试] 解析到事件类型:', currentEvent);
// 注意:后端统一使用 "message" 事件名,真正的类型在 data 的 JSON 中
continue;
} else if (line.startsWith('data:')) {
// 兼容 "data:xxx" 和 "data: xxx" 两种格式
const rawData = line.substring(5).trim();
console.log('[SSE调试] 解析到数据, currentEvent:', currentEvent, ', rawData:', rawData);
// 兼容旧格式 [DONE] 标记
if (rawData === '[DONE]') {
// 流结束标记,将内容转换为Markdown渲染
this.handleStreamComplete(assistantMessageElement, fullResponse);
return;
}
// 处理 SSE 数据
try {
// 尝试解析为 SseMessage 格式的 JSON
const sseMessage = JSON.parse(rawData);
console.log('[SSE调试] 解析JSON成功:', sseMessage);
if (sseMessage && typeof sseMessage.type === 'string') {
if (sseMessage.type === 'metadata') {
this.rememberRunMetadata(sseMessage);
} else if (sseMessage.type === 'content') {
const content = sseMessage.data || '';
fullResponse += content;
console.log('[SSE调试] 添加内容:', content);
// 实时渲染 Markdown
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
// 高亮代码块
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
} else if (sseMessage.type === 'done') {
console.log('[SSE调试] 收到done标记,流结束');
this.handleStreamComplete(assistantMessageElement, fullResponse);
return;
} else if (sseMessage.type === 'error') {
console.error('[SSE调试] 收到错误:', sseMessage.data);
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown('错误: ' + (sseMessage.data || '未知错误'));
}
return;
}
} else {
// 不是标准 SseMessage 格式,尝试兼容处理
console.log('[SSE调试] 非标准格式,尝试兼容处理');
fullResponse += rawData;
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
}
} catch (e) {
// JSON 解析失败,尝试兼容旧格式
console.log('[SSE调试] JSON解析失败,使用兼容模式:', e.message);
if (rawData === '') {
fullResponse += '\n';
} else {
fullResponse += rawData;
}
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
}
}
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
throw error;
renderChatPayload(contentType, payload) {
if (contentType === 'SYSTEM_CHAT') return payload.answer || '';
if (contentType === 'KNOWLEDGE_ANSWER') {
const references = (payload.references || []).map(ref => `- ${ref.title || ref.document_id}`).join('\n');
return [payload.answer || '', references ? `\n\n参考资料:\n${references}` : ''].join('');
}
if (contentType === 'SAFE_FALLBACK') {
return payload.fallback?.message || '当前证据不足,无法确认根因';
}
if (contentType === 'DIAGNOSIS_REPORT') {
const report = payload.report || {};
const sections = [];
if (report.conclusion?.text) sections.push(`## 结论\n${report.conclusion.text}`);
if (report.analysis?.length) sections.push(`## 分析\n${report.analysis.map(item => `- ${item.text}`).join('\n')}`);
if (report.action_plan?.length) sections.push(`## 行动计划\n${report.action_plan.map(item => `- ${item.action}`).join('\n')}`);
if (report.recommendations?.length) sections.push(`## 建议\n${report.recommendations.map(item => `- ${item.text}`).join('\n')}`);
if (report.limitations?.scope) sections.push(`## 限制\n${report.limitations.scope}`);
return sections.join('\n\n');
}
throw new Error(`未知 content_type: ${contentType}`);
}
// 添加消息到聊天界面
-24
View File
@@ -95,30 +95,6 @@
</div>
</div>
<div class="right-actions">
<div class="mode-selector-wrapper">
<button class="mode-selector-btn" id="modeSelectorBtn">
<span id="currentModeText">快速</span>
<svg class="dropdown-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="mode-dropdown" id="modeDropdown">
<div class="dropdown-header">选择对话方式</div>
<div class="dropdown-item active" data-mode="quick">
<div class="dropdown-item-main">
<span>快速</span>
<span class="badge-new">新</span>
</div>
<div class="dropdown-item-sub">快速对话</div>
</div>
<div class="dropdown-item" data-mode="stream">
<div class="dropdown-item-main">
<span>流式</span>
</div>
<div class="dropdown-item-sub">流式对话</div>
</div>
</div>
</div>
<button class="send-btn-circle" id="sendButton" title="发送">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-118
View File
@@ -735,108 +735,6 @@ body {
color: #9aa0a6;
}
.mode-selector-wrapper {
position: relative;
}
.mode-selector-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #5f6368;
font-weight: 400;
transition: color 0.2s;
white-space: nowrap;
}
.mode-selector-btn:hover {
color: #202124;
}
.dropdown-arrow {
width: 16px;
height: 16px;
transition: transform 0.2s;
}
.mode-selector-wrapper.active .dropdown-arrow {
transform: rotate(180deg);
}
.mode-dropdown {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
background: #ffffff;
border: 1px solid #dadce0;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(60, 64, 67, 0.15);
min-width: 200px;
opacity: 0;
visibility: hidden;
transform: translateY(8px);
transition: all 0.2s;
z-index: 1000;
}
.mode-selector-wrapper.active .mode-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-header {
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: #5f6368;
text-transform: uppercase;
border-bottom: 1px solid #dadce0;
}
.dropdown-item {
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.dropdown-item:hover {
background: #f1f3f4;
}
.dropdown-item.active {
background: rgba(26, 115, 232, 0.1);
}
.dropdown-item-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
color: #202124;
margin-bottom: 4px;
}
.dropdown-item-sub {
font-size: 12px;
color: #5f6368;
}
.badge-new {
background: #1a73e8;
color: #ffffff;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
.send-btn-circle {
width: 36px;
height: 36px;
@@ -1146,22 +1044,6 @@ body {
flex-wrap: wrap;
}
.mode-selector-wrapper {
order: -1;
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
.mode-selector-btn {
width: 100%;
justify-content: space-between;
}
.mode-dropdown {
width: 100%;
right: 0;
}
}
/* 反馈栏 */
@@ -0,0 +1,91 @@
package com.superbiz.agent.config;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
import com.superbiz.agent.harness.tool.mysql.MysqlSecurityException;
import com.superbiz.agent.tool.LookupKnowledgeTool;
import com.superbiz.agent.tool.RetrievedDocTracker;
import com.superbiz.agent.service.KnowledgeContextPacker;
import com.superbiz.agent.service.KnowledgeDocumentRetriever;
import com.superbiz.agent.service.KnowledgeEvidencePostProcessor;
import com.superbiz.agent.service.KnowledgeQueryTransformer;
import com.superbiz.agent.service.LookupResultAssembler;
import com.superbiz.agent.service.ToolInvocationRecorder;
import com.superbiz.agent.repository.AgentStepRepository;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.data.redis.core.RedisTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.ThreadPoolExecutor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class HarnessChatConfigurationTest {
@Test
void defaultsArePositiveAndExecutorsAreBounded() {
ChatHarnessProperties properties = new ChatHarnessProperties();
properties.validate();
HarnessChatConfiguration configuration = new HarnessChatConfiguration();
ThreadPoolExecutor worker = configuration.chatWorkerExecutor(properties);
ThreadPoolExecutor model = configuration.harnessModelExecutor(properties);
try {
assertTrue(worker.getQueue().remainingCapacity() > 0);
assertTrue(model.getQueue().remainingCapacity() > 0);
assertEquals(properties.getWorkerMaxPoolSize(), worker.getMaximumPoolSize());
assertEquals(properties.getModelMaxPoolSize(), model.getMaximumPoolSize());
} finally {
worker.shutdownNow();
model.shutdownNow();
}
}
@Test
void emptyMysqlConfigurationFailsClosedWithoutApplicationDatasource() {
MysqlToolProperties properties = new MysqlToolProperties();
HarnessChatConfiguration configuration = new HarnessChatConfiguration();
assertTrue(configuration.harnessMysqlDataSources(properties).isEmpty());
Map<String, com.superbiz.agent.harness.tool.mysql.MysqlDataSourceDefinition> definitions =
configuration.harnessMysqlDefinitions(properties);
MysqlSqlValidator validator = configuration.mysqlSqlValidator(definitions);
assertThrows(MysqlSecurityException.class, () -> validator.validate(
new MysqlToolRequest("application", "SELECT id FROM users", java.util.List.of())));
}
@Test
void harnessConfigurationBuildsOneApplicationGraphWithControlledDependencies() {
new ApplicationContextRunner()
.withUserConfiguration(HarnessChatConfiguration.class)
.withBean(ObjectMapper.class, ObjectMapper::new)
.withBean(ChatModel.class, () -> mock(ChatModel.class))
.withBean(RedisTemplate.class, () -> mock(RedisTemplate.class))
.withBean(LookupKnowledgeTool.class, () -> mock(LookupKnowledgeTool.class))
.withBean(KnowledgeQueryTransformer.class, () -> mock(KnowledgeQueryTransformer.class))
.withBean(KnowledgeDocumentRetriever.class, () -> mock(KnowledgeDocumentRetriever.class))
.withBean(KnowledgeEvidencePostProcessor.class, () -> mock(KnowledgeEvidencePostProcessor.class))
.withBean(KnowledgeContextPacker.class, () -> mock(KnowledgeContextPacker.class))
.withBean(LookupResultAssembler.class, () -> mock(LookupResultAssembler.class))
.withBean(ToolInvocationRecorder.class, () -> mock(ToolInvocationRecorder.class))
.withBean(RetrievedDocTracker.class, () -> mock(RetrievedDocTracker.class))
.withBean(AgentStepRepository.class, () -> mock(AgentStepRepository.class))
.withBean(ChatRunStore.class, () -> mock(ChatRunStore.class))
.withBean(MysqlToolProperties.class, MysqlToolProperties::new)
.run(context -> {
assertTrue(context.isRunning());
assertEquals(1, context.getBeansOfType(
com.superbiz.agent.harness.application.ChatApplicationUseCase.class).size());
assertTrue(context.containsBean("harnessModelExecutor"));
assertTrue(context.containsBean("chatWorkerExecutor"));
});
}
}
@@ -0,0 +1,17 @@
package com.superbiz.agent.controller;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AiOpsControllerTest {
@Test
void metadataMessageCarriesSessionAndRunId() {
AiOpsController.SseMessage message = AiOpsController.SseMessage.metadata("session-1", "run-1");
assertEquals("metadata", message.getType());
assertEquals("session-1", message.getSessionId());
assertEquals("run-1", message.getRunId());
}
}
@@ -1,12 +1,19 @@
package com.superbiz.agent.controller;
import com.superbiz.agent.service.ChatService;
import com.superbiz.agent.config.ChatHarnessProperties;
import com.superbiz.agent.harness.application.ChatApplicationUseCase;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -14,28 +21,59 @@ class ChatControllerTest {
@Test
void blankChatRequestReturnsErrorBeforeCreatingRun() {
ChatController controller = new ChatController();
ChatService chatService = mock(ChatService.class);
ReflectionTestUtils.setField(controller, "chatService", chatService);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1));
ChatApplicationUseCase application = mock(ChatApplicationUseCase.class);
ChatController controller = new ChatController(
application, executor, new ChatHarnessProperties());
ChatController.ChatRequest request = new ChatController.ChatRequest();
request.setId("invalid-chat-session");
request.setQuestion(" ");
ResponseEntity<ChatController.ApiResponse<ChatController.ChatResponse>> response = controller.chat(request);
ResponseEntity<?> response = controller.chat(request);
ChatController.ChatResponse body = response.getBody().getData();
assertFalse(body.isSuccess());
assertEquals("问题内容不能为空", body.getErrorMessage());
verifyNoInteractions(chatService);
assertEquals(400, response.getStatusCode().value());
verifyNoInteractions(application);
executor.shutdownNow();
}
@Test
void aiOpsMetadataMessageCarriesSessionAndRunId() {
ChatController.SseMessage message = ChatController.SseMessage.metadata("session-1", "run-1");
void controllerOwnsOnlyChatProtocolAndApplicationDependencies() {
Set<Class<?>> dependencyTypes = Arrays.stream(ChatController.class.getDeclaredFields())
.map(Field::getType)
.collect(java.util.stream.Collectors.toSet());
assertEquals("metadata", message.getType());
assertEquals("session-1", message.getSessionId());
assertEquals("run-1", message.getRunId());
assertEquals(Set.of(ChatApplicationUseCase.class, ThreadPoolExecutor.class, ChatHarnessProperties.class),
dependencyTypes);
}
@Test
void saturatedWorkerReturnsUnavailableBeforeCreatingRun() throws Exception {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1));
CountDownLatch release = new CountDownLatch(1);
executor.execute(() -> await(release));
executor.execute(() -> await(release));
ChatApplicationUseCase application = mock(ChatApplicationUseCase.class);
ChatController controller = new ChatController(
application, executor, new ChatHarnessProperties());
ChatController.ChatRequest request = new ChatController.ChatRequest();
request.setQuestion("hello");
ResponseEntity<?> response = controller.chat(request);
assertEquals(503, response.getStatusCode().value());
verifyNoInteractions(application);
release.countDown();
executor.shutdownNow();
}
private static void await(CountDownLatch release) {
try {
release.await(2, TimeUnit.SECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,36 @@
package com.superbiz.agent.controller;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FrontendChatSseContractTest {
private static final Path STATIC_ROOT = Path.of("src", "main", "resources", "static");
@Test
void bundledChatUsesOnlyTheNewSseConsumer() throws IOException {
String app = Files.readString(STATIC_ROOT.resolve("app.js"), StandardCharsets.UTF_8);
String html = Files.readString(STATIC_ROOT.resolve("index.html"), StandardCharsets.UTF_8);
assertTrue(app.contains("sendChatMessage"));
assertTrue(app.contains("/chat`"));
assertTrue(app.contains("case 'metadata'"));
assertTrue(app.contains("case 'status'"));
assertTrue(app.contains("case 'content'"));
assertTrue(app.contains("case 'failure'"));
assertTrue(app.contains("case 'done'"));
assertFalse(app.contains("/chat_stream"));
assertFalse(app.contains("sendQuickMessage"));
assertFalse(app.contains("sendStreamMessage"));
assertFalse(app.contains("currentMode"));
assertFalse(html.contains("modeSelectorBtn"));
assertFalse(html.contains("modeDropdown"));
}
}
@@ -0,0 +1,143 @@
package com.superbiz.agent.controller.sse;
import com.superbiz.agent.harness.application.ChatApplicationResult;
import com.superbiz.agent.harness.application.ChatApplicationException;
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.application.ChatRunControl;
import com.superbiz.agent.harness.application.SystemChatContent;
import com.superbiz.agent.harness.contract.IntentType;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChatSseSessionTest {
@Test
void successEmitsStrictNamedEventSequenceWithTypedContent() {
RecordingSink sink = new RecordingSink();
ChatSseSession session = new ChatSseSession(sink);
RecordingRunControl control = new RecordingRunControl();
session.onStarted(control);
session.onStatus(ChatApplicationStatus.ROUTING);
session.complete(new ChatApplicationResult(
"session-1", "run-1", IntentType.SYSTEM_CHAT, ReleaseOutcome.SUCCESS,
ChatContentType.SYSTEM_CHAT, new SystemChatContent("hello")));
assertEquals(List.of("metadata", "status", "content", "done"), sink.names());
assertEquals(new ChatSseEvent.Metadata("session-1", "run-1"), sink.events.get(0).payload());
assertEquals(new ChatSseEvent.Content(
ChatContentType.SYSTEM_CHAT, new SystemChatContent("hello")),
sink.events.get(2).payload());
assertEquals(new ChatSseEvent.Done(ReleaseOutcome.SUCCESS), sink.events.get(3).payload());
assertEquals(1, sink.completions);
assertFalse(control.cancelled);
}
@Test
void technicalFailureEmitsSafeFailureAndFailedDoneWithoutContent() {
RecordingSink sink = new RecordingSink();
ChatSseSession session = new ChatSseSession(sink);
session.onStarted(new RecordingRunControl());
session.fail(new ChatApplicationException(
ChatFailureCode.ROUTING_UNAVAILABLE,
"当前暂时无法处理该请求,请稍后重试",
new IllegalStateException("provider-secret-detail")));
assertEquals(List.of("metadata", "failure", "done"), sink.names());
assertEquals(new ChatSseEvent.Failure(
ChatFailureCode.ROUTING_UNAVAILABLE,
"当前暂时无法处理该请求,请稍后重试"), sink.events.get(1).payload());
assertEquals(new ChatSseEvent.Done(ReleaseOutcome.FAILED), sink.events.get(2).payload());
assertFalse(sink.events.toString().contains("provider-secret-detail"));
}
@Test
void disconnectBeforeRunStartCancelsControlWhenPublishedWithoutSendingEvents() {
RecordingSink sink = new RecordingSink();
ChatSseSession session = new ChatSseSession(sink);
RecordingRunControl control = new RecordingRunControl();
assertFalse(session.disconnect());
session.onStarted(control);
assertTrue(control.cancelled);
assertTrue(sink.events.isEmpty());
assertEquals(0, sink.completions);
}
@Test
void statusSendFailureCancelsThePublishedRunAndBlocksLateTerminalEvents() {
RecordingSink sink = new RecordingSink();
sink.failOn = "status";
ChatSseSession session = new ChatSseSession(sink);
RecordingRunControl control = new RecordingRunControl();
session.onStarted(control);
assertThrows(ChatSseDisconnectedException.class,
() -> session.onStatus(ChatApplicationStatus.ROUTING));
assertTrue(control.cancelled);
assertFalse(session.complete(new ChatApplicationResult(
"session-1", "run-1", IntentType.SYSTEM_CHAT, ReleaseOutcome.SUCCESS,
ChatContentType.SYSTEM_CHAT, new SystemChatContent("late"))));
assertEquals(List.of("metadata"), sink.names());
}
private static final class RecordingSink implements ChatSseSink {
private final List<Event> events = new ArrayList<>();
private int completions;
private String failOn;
@Override
public void send(String name, Object payload) throws IOException {
if (name.equals(failOn)) {
throw new IOException("closed");
}
events.add(new Event(name, payload));
}
@Override
public void complete() {
completions++;
}
private List<String> names() {
return events.stream().map(Event::name).toList();
}
}
private static final class RecordingRunControl implements ChatRunControl {
private boolean cancelled;
@Override
public String sessionId() {
return "session-1";
}
@Override
public String runId() {
return "run-1";
}
@Override
public boolean cancelClientDisconnect() {
cancelled = true;
return true;
}
}
private record Event(String name, Object payload) {
}
}