feat(chat): cut over to single SSE endpoint
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 执行状态中提取最终报告文本。
|
||||
*
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
// 添加消息到聊天界面
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* 反馈栏 */
|
||||
|
||||
Reference in New Issue
Block a user