[GSOC] Chat UI, Conversation Management, OpenAI Chat Client Support, More monitoring tools. (#3679)

Signed-off-by: Sarthak Arora <f20200060@pilani.bits-pilani.ac.in>
Co-authored-by: Calvin <zhengqiwei@apache.org>
Co-authored-by: Jast <shenghang@apache.org>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: DeleiGuo <deleiguo@163.com>
Co-authored-by: shown <yuluo08290126@gmail.com>
Co-authored-by: tomsun28 <tomsun28@outlook.com>
Co-authored-by: Logic <zqr10159@dromara.org>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sarthak Arora
2025-09-07 22:36:04 +08:00
committed by GitHub
co-authored by Calvin Jast Duansg DeleiGuo shown tomsun28 Logic Copilot
parent 1fae67282d
commit feb43c5678
47 changed files with 3999 additions and 545 deletions
+4
View File
@@ -35,6 +35,10 @@
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -20,7 +20,10 @@ package org.apache.hertzbeat.ai.agent.adapters;
import org.springframework.data.domain.Page;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.entity.manager.ParamDefine;
import java.util.List;
import java.util.Map;
/**
* Interface that provides access to monitor information by retrieving monitor data
@@ -28,14 +31,41 @@ import java.util.List;
*/
public interface MonitorServiceAdapter {
Page<Monitor> getMonitors(
List<Long> ids,
String app,
String search,
Byte status,
String sort,
String order,
Integer pageIndex,
Integer pageSize,
String labels
List<Long> ids,
String app,
String search,
Byte status,
String sort,
String order,
Integer pageIndex,
Integer pageSize,
String labels
);
/**
* Add a new monitor
*
* @param monitor The monitor entity to create
* @param params List of parameters for the monitor
* @param collector Optional collector assignment
* @return The created monitor ID
*/
Long addMonitor(Monitor monitor, List<Param> params, String collector);
/**
* Get all available monitor types with their display names
*
* @param language Language code (e.g., "en-US", "zh-CN")
* @return Map of monitor type key to display name
*/
Map<String, String> getAvailableMonitorTypes(String language);
/**
* Get parameter definitions for a specific monitor type
*
* @param app Monitor type/application name (e.g., "linux", "mysql", "redis")
* @return List of parameter definitions for the monitor type
*/
List<ParamDefine> getMonitorParamDefines(String app);
}
@@ -24,11 +24,14 @@ import org.apache.hertzbeat.ai.agent.adapters.MonitorServiceAdapter;
import org.apache.hertzbeat.ai.agent.config.McpContextHolder;
import org.springframework.data.domain.Page;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.entity.manager.ParamDefine;
import org.apache.hertzbeat.common.support.SpringContextHolder;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
/**
* Implementation of the MonitorServiceAdapter interface that provides access to monitor information
@@ -63,7 +66,7 @@ public class MonitorServiceAdapterImpl implements MonitorServiceAdapter {
if (pageSize == null) {
pageSize = 8;
}
Object monitorService = null;
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current security subject: {}", subjectSum);
@@ -97,4 +100,116 @@ public class MonitorServiceAdapterImpl implements MonitorServiceAdapter {
}
}
}
@Override
public Long addMonitor(Monitor monitor, List<Param> params, String collector) {
try {
Object monitorService = null;
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current security subject for addMonitor: {}", subjectSum);
try {
monitorService = SpringContextHolder.getBean("monitorServiceImpl");
} catch (Exception e) {
log.debug("Could not find bean by name 'monitorServiceImpl', trying by class name");
}
assert monitorService != null;
log.debug("MonitorService bean found for addMonitor: {}", monitorService.getClass().getSimpleName());
// Call addMonitor method: addMonitor(Monitor monitor, List<Param> params, String collector, GrafanaDashboard dashboard)
Method method = monitorService.getClass().getMethod(
"addMonitor",
Monitor.class, List.class, String.class,
Class.forName("org.apache.hertzbeat.common.entity.grafana.GrafanaDashboard"));
// Call the method with null dashboard
method.invoke(monitorService, monitor, params, collector, null);
log.debug("Successfully added monitor: {} with ID: {}", monitor.getName(), monitor.getId());
return monitor.getId();
} catch (NoSuchMethodException e) {
throw new RuntimeException("Method not found: addMonitor", e);
} catch (Exception e) {
log.error("Failed to invoke addMonitor via adapter", e);
throw new RuntimeException("Failed to invoke addMonitor via adapter: " + e.getMessage(), e);
}
}
@Override
public Map<String, String> getAvailableMonitorTypes(String language) {
try {
Object appService = null;
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current security subject for getAvailableMonitorTypes: {}", subjectSum);
try {
appService = SpringContextHolder.getBean("appServiceImpl");
} catch (Exception e) {
log.debug("Could not find bean by name 'appServiceImpl', trying by class name");
}
assert appService != null;
log.debug("AppService bean found for getAvailableMonitorTypes: {}", appService.getClass().getSimpleName());
// Provide default language if not specified
if (language == null || language.trim().isEmpty()) {
language = "en-US";
}
// Call getI18nApps method: getI18nApps(String lang)
Method method = appService.getClass().getMethod("getI18nApps", String.class);
@SuppressWarnings("unchecked")
Map<String, String> result = (Map<String, String>) method.invoke(appService, language);
log.debug("Successfully retrieved {} monitor types", result.size());
return result;
} catch (NoSuchMethodException e) {
throw new RuntimeException("Method not found: getI18nApps", e);
} catch (Exception e) {
log.error("Failed to invoke getI18nApps via adapter", e);
throw new RuntimeException("Failed to invoke getI18nApps via adapter: " + e.getMessage(), e);
}
}
@Override
public List<ParamDefine> getMonitorParamDefines(String app) {
try {
Object appService = null;
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current security subject for getMonitorParamDefines: {}", subjectSum);
try {
appService = SpringContextHolder.getBean("appServiceImpl");
} catch (Exception e) {
log.debug("Could not find bean by name 'appServiceImpl', trying by class name");
}
assert appService != null;
log.debug("AppService bean found for getMonitorParamDefines: {}", appService.getClass().getSimpleName());
// Validate app parameter
if (app == null || app.trim().isEmpty()) {
throw new IllegalArgumentException("Monitor type/app parameter is required");
}
// Call getAppParamDefines method: getAppParamDefines(String app)
Method method = appService.getClass().getMethod("getAppParamDefines", String.class);
@SuppressWarnings("unchecked")
List<ParamDefine> result = (List<ParamDefine>) method.invoke(appService, app.toLowerCase().trim());
log.debug("Successfully retrieved {} parameter definitions for monitor type: {}", result.size(), app);
return result;
} catch (NoSuchMethodException e) {
throw new RuntimeException("Method not found: getAppParamDefines", e);
} catch (Exception e) {
log.error("Failed to invoke getAppParamDefines via adapter for app: {}", app, e);
throw new RuntimeException("Failed to invoke getAppParamDefines via adapter: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,57 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.ai.model.ApiKey;
import org.springframework.stereotype.Component;
/**
* Dynamic OpenAI API Key implementation that retrieves the API key
* from our configuration service (database first, then YAML fallback)
*/
@Slf4j
@Component
public class DynamicOpenAiApiKey implements ApiKey {
private final OpenAiConfigService openAiConfigService;
public DynamicOpenAiApiKey(OpenAiConfigService openAiConfigService) {
this.openAiConfigService = openAiConfigService;
}
@Override
public String getValue() {
try {
OpenAiConfigDto effectiveConfig = openAiConfigService.getEffectiveConfig();
if (effectiveConfig != null && effectiveConfig.isEnable() && effectiveConfig.getApiKey() != null) {
log.debug("Retrieved OpenAI API key from configuration service");
return effectiveConfig.getApiKey();
} else {
log.warn("No valid OpenAI API key found in configuration");
return null;
}
} catch (Exception e) {
log.error("Error retrieving OpenAI API key from configuration", e);
return null;
}
}
}
@@ -20,6 +20,8 @@ package org.apache.hertzbeat.ai.agent.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -29,9 +31,42 @@ import org.springframework.context.annotation.Configuration;
@Configuration
public class LlmConfig {
/**
* Create OpenAI API instance with dynamic API key
*/
@Bean
public ChatClient openAiChatClient(OpenAiChatModel chatModel) {
return ChatClient.create(chatModel);
public OpenAiApi openAiApi(DynamicOpenAiApiKey dynamicApiKey) {
return OpenAiApi.builder()
.apiKey(dynamicApiKey)
.build();
}
}
/**
* Create OpenAI Chat Options with custom settings
*/
@Bean
public OpenAiChatOptions openAiChatOptions() {
return OpenAiChatOptions.builder()
.model("gpt-4.1-nano-2025-04-14")
.temperature(0.3)
.build();
}
/**
* Create OpenAI Chat Model with custom API configuration
*/
@Bean
public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi, OpenAiChatOptions openAiChatOptions) {
return OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(openAiChatOptions)
.build();
}
@Bean
public ChatClient openAiChatClient(OpenAiChatModel openAiChatModel) {
return ChatClient.create(openAiChatModel);
}
}
@@ -15,12 +15,29 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.config;
package org.apache.hertzbeat.ai.agent.service;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Service interface for agent operations.
* OpenAI YAML Configuration - reads from spring.ai.openai.api-key
*/
public interface AgentService {
@Data
@Component
@ConfigurationProperties(prefix = "spring.ai.openai")
public class OpenAiYamlConfig {
}
/**
* OpenAI API key from spring.ai.openai.api-key
*/
private String apiKey;
/**
* Check if OpenAI is enabled (has API key)
*/
public boolean isEnable() {
return apiKey != null && !apiKey.trim().isEmpty();
}
}
@@ -31,23 +31,47 @@ public class PromptProvider {
*/
public static final String HERTZBEAT_MONITORING_PROMPT = """
You are an AI assistant specialized in monitoring infrastructure and applications with HertzBeat.
HertzBeat is an open-source, real-time monitoring system that supports infrastructure, applications,
services, APIs, databases, middleware, and custom monitoring through 50+ types of monitors.
Your role is to help users manage and analyze their monitoring data using the available tools.
You have access to the following HertzBeat monitoring tools:
- list_monitors: Query monitor information with flexible filtering and pagination
- add_monitor: Add a new monitor to the system
When users ask questions about their monitoring setup or data, identify which tool would be most helpful
and use it to provide relevant information. Always provide clear explanations of the monitoring data and
suggest next steps or insights based on the results.
For monitoring-related queries:
1. If users want to see their monitors, use list_monitors with appropriate filters
2. If users want to add a new monitor, use add_monitor with the necessary details
3. If the monitoring information shows potential issues, highlight them and suggest troubleshooting steps
For parameters that accept specific values:
- Monitor status values: 0 (no monitor), 1 (usable), 2 (disabled), 9 (all)
- Sort fields typically include: name, host, app, gmtCreate
- Sort order should be 'asc' or 'desc'
Keep responses focused on monitoring topics and HertzBeat capabilities.
If you're unsure about specific monitoring details, ask clarifying questions before using the tools.
## Available HertzBeat Monitoring Tools:
- **list_monitors**: Query monitor information with detailed output (ID, name, type, host, status)
- **add_monitor**: Add a new monitor to the system with comprehensive configuration
- **list_monitor_types**: List all available monitor types (linux, mysql, redis, http, etc.)
- **get_monitor_param_defines**: Get parameter definitions required for specific monitor types
## HertzBeat Monitor Types:
HertzBeat supports monitoring of:
- **Operating Systems**: Linux, Windows, FreeBSD, macOS, etc.
- **Databases**: MySQL, PostgreSQL, Redis, MongoDB, Oracle, SQL Server, etc.
- **Application Services**: Tomcat, Spring Boot, Elasticsearch, Kafka, etc.
- **Network & Infrastructure**: HTTP/HTTPS websites, DNS, ping, SSL certificates, etc.
- **Cloud Services**: AWS, Azure, Kubernetes, Docker, etc.
- **Custom Monitoring**: Through YAML templates and various protocols (HTTP, JDBC, SSH, JMX, SNMP, etc.)
## Workflow Guidelines:
1. **For viewing monitors**: Use list_monitors with appropriate filters (by type, status, host, etc.)
2. **For adding monitors**:
- First use list_monitor_types to show available types
- Then use get_monitor_param_defines to show required parameters for the chosen type
- Finally use add_monitor with all necessary parameters
## Parameter Values:
- **Monitor status**: 0 (no monitor), 1 (usable), 2 (disabled), 9 (all)
- **Sort fields**: name, host, app, gmtCreate, gmtUpdate
- **Sort order**: 'asc' or 'desc'
- **Monitor intervals**: Typically 30s to 3600s (30 seconds to 1 hour)
## Best Practices:
- Always validate monitor types using list_monitor_types before adding
- Check parameter requirements using get_monitor_param_defines for each monitor type
- Provide clear explanations of monitoring data and suggest actionable insights
- For performance issues, recommend appropriate collection intervals
- Explain HertzBeat's template-based YAML monitoring definitions when relevant
Keep responses focused on monitoring topics and HertzBeat's comprehensive monitoring capabilities.
If you're unsure about specific monitoring requirements, use the parameter definition tools to get
accurate information.
""";
}
}
@@ -15,56 +15,183 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.controller;
import org.springframework.ai.chat.client.ChatClient;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.config.McpContextHolder;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatResponseDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.ConversationDto;
import org.apache.hertzbeat.ai.agent.service.ConversationService;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.agent.service.ChatClientProviderService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import javax.validation.Valid;
import java.util.List;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
/**
* Controller class for handling chat-related HTTP requests.
* Controller class for handling AI chat requests and conversation management.
*/
@Slf4j
@Tag(name = "AI Chat API")
@RestController
@RequestMapping("/api/chat")
@RequestMapping(path = "/api/chat", produces = {APPLICATION_JSON_VALUE})
public class ChatController {
private final ChatClientProviderService chatClientProviderService;
private final ConversationService conversationService;
@Autowired
public ChatController(@Qualifier("openAiChatClient") ChatClient openAiChatClient,
ChatClientProviderService chatClientProviderService) {
this.chatClientProviderService = chatClientProviderService;
public ChatController(ConversationService conversationService) {
this.conversationService = conversationService;
}
/**
* Send a message and get a streaming response
*
* @param context The chat request context containing message and optional
* conversationId
* @return SSE emitter for streaming response
* Create a new conversation
*
* @return Created conversation details
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(@RequestBody ChatRequestContext context) {
SseEmitter emitter = new SseEmitter();
new Thread(() -> {
try {
String aiResponse = chatClientProviderService.streamChat(context);
emitter.send(aiResponse);
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
}).start();
return emitter;
@PostMapping(path = "/conversations")
@Operation(summary = "Create a new conversation", description = "Create a new conversation")
public ResponseEntity<Message<ConversationDto>> createConversation() {
try {
ConversationDto conversation = conversationService.createConversation();
return ResponseEntity.ok(Message.success(conversation));
} catch (Exception e) {
log.error("Error creating conversation: ", e);
return ResponseEntity.ok(Message.fail((byte) -1, "Failed to create conversation"));
}
}
}
/**
* Send a message and get a streaming response with conversation tracking
*
* @param context The chat request context containing message and optional conversationId
* @return Flux of ServerSentEvent for streaming response
*/
@PostMapping(value = "/stream", produces = TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "Send a chat message with streaming response", description = "Send a message to AI and get a streaming response with conversation tracking")
public Flux<ServerSentEvent<ChatResponseDto>> streamChat(@Valid @RequestBody ChatRequestContext context) {
try {
// Validate message is not empty
SubjectSum subject = SurenessContextHolder.getBindSubject();
log.info(subject.toString());
McpContextHolder.setSubject(subject);
if (context.getMessage() == null || context.getMessage().trim().isEmpty()) {
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(context.getConversationId())
.response("Error: Message cannot be empty")
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
.build());
}
log.info("Received streaming chat request for conversation: {}", context.getConversationId());
return conversationService.streamChat(context.getMessage(), context.getConversationId());
} catch (Exception e) {
log.error("Error in stream chat endpoint: ", e);
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(context.getConversationId())
.response("An error occurred: " + e.getMessage())
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
.build());
}
}
/**
* Get all conversations
*
* @return List of all conversations
*/
@GetMapping(path = "/conversations")
@Operation(summary = "List all conversations", description = "Get a list of all conversations")
public ResponseEntity<Message<List<ConversationDto>>> listConversations() {
try {
List<ConversationDto> conversations = conversationService.getAllConversations();
return ResponseEntity.ok(Message.success(conversations));
} catch (Exception e) {
log.error("Error listing conversations: ", e);
return ResponseEntity.ok(Message.fail((byte) -1, "Failed to retrieve conversations"));
}
}
/**
* Get conversation history
*
* @param conversationId The conversation ID
* @return Conversation details with message history
*/
@GetMapping(path = "/conversations/{conversationId}")
@Operation(summary = "Get conversation history", description = "Get detailed information and message history for a specific conversation")
public ResponseEntity<Message<ConversationDto>> getConversation(
@Parameter(description = "Conversation ID", example = "conv-12345678") @PathVariable("conversationId") String conversationId) {
try {
// Validate conversation ID
if (conversationId == null || conversationId.trim().isEmpty()) {
return ResponseEntity.ok(Message.fail((byte) -1, "Conversation ID is required"));
}
ConversationDto conversation = conversationService.getConversation(conversationId);
if (conversation == null) {
return ResponseEntity.ok(Message.fail((byte) -1, "Conversation not found: " + conversationId));
}
return ResponseEntity.ok(Message.success(conversation));
} catch (Exception e) {
log.error("Error getting conversation: ", e);
return ResponseEntity.ok(Message.fail((byte) -1, "Failed to retrieve conversation"));
}
}
/**
* Delete a conversation
*
* @param conversationId The conversation ID to delete
* @return Success or error message
*/
@DeleteMapping(path = "/conversations/{conversationId}")
@Operation(summary = "Delete conversation", description = "Delete a specific conversation and all its messages")
public ResponseEntity<Message<Void>> deleteConversation(
@Parameter(description = "Conversation ID", example = "conv-12345678") @PathVariable("conversationId") String conversationId) {
try {
// Validate conversation ID
if (conversationId == null || conversationId.trim().isEmpty()) {
return ResponseEntity.ok(Message.fail((byte) -1, "Conversation ID is required"));
}
boolean deleted = conversationService.deleteConversation(conversationId);
if (!deleted) {
return ResponseEntity.ok(Message.fail((byte) -1, "Conversation not found: " + conversationId));
}
return ResponseEntity.ok(Message.success("Conversation deleted successfully"));
} catch (Exception e) {
log.error("Error deleting conversation: ", e);
return ResponseEntity.ok(Message.fail((byte) -1, "Failed to delete conversation"));
}
}
}
@@ -0,0 +1,160 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.http.ResponseEntity;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
/**
* OpenAI Configuration API
*/
@RestController
@RequestMapping(value = "/api/ai-agent/config", produces = {APPLICATION_JSON_VALUE})
@Tag(name = "OpenAI Configuration API")
@Slf4j
public class OpenAiConfigController {
private final OpenAiConfigService openAiConfigService;
public OpenAiConfigController(OpenAiConfigService openAiConfigService) {
this.openAiConfigService = openAiConfigService;
}
@PostMapping("/openai")
@Operation(summary = "Save OpenAI configuration", description = "Save or update OpenAI configuration")
public ResponseEntity<Map<String, Object>> saveOpenAiConfig(@Valid @RequestBody OpenAiConfigDto config) {
try {
Map<String, Object> response = new HashMap<>();
// Validate API key if enabled
if (config.isEnable() && config.getApiKey() != null && !config.getApiKey().trim().isEmpty()) {
OpenAiConfigService.ValidationResult validationResult = openAiConfigService.validateApiKey(config.getApiKey());
if (!validationResult.isValid()) {
log.warn("API key validation failed during save: {}", validationResult.getMessage());
response.put("code", 1);
response.put("msg", "API key validation failed: " + validationResult.getMessage());
return ResponseEntity.ok(response);
}
log.info("API key validation successful during save");
}
// Save the configuration
openAiConfigService.saveConfig(config);
response.put("code", 0);
response.put("msg", "OpenAI configuration saved successfully");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to save OpenAI configuration", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to save configuration: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
@GetMapping("/openai")
@Operation(summary = "Get OpenAI configuration", description = "Get current OpenAI configuration")
public ResponseEntity<Map<String, Object>> getOpenAiConfig() {
try {
OpenAiConfigDto config = openAiConfigService.getConfig();
Map<String, Object> response = new HashMap<>();
response.put("code", 0);
response.put("data", config);
response.put("msg", "Success");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get OpenAI configuration", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to get configuration: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
@GetMapping("/openai/status")
@Operation(summary = "Check OpenAI configuration status", description = "Check if OpenAI is properly configured")
public ResponseEntity<Map<String, Object>> getOpenAiConfigStatus() {
try {
boolean configured = openAiConfigService.isConfigured();
OpenAiConfigDto effectiveConfig = openAiConfigService.getEffectiveConfig();
boolean hasDbConfig = openAiConfigService.getConfig() != null;
boolean hasYamlConfig = effectiveConfig != null && !hasDbConfig;
// Validate the effective configuration
boolean validationPassed = false;
String validationMessage = "No configuration found";
if (effectiveConfig != null && effectiveConfig.isEnable() && effectiveConfig.getApiKey() != null && !effectiveConfig.getApiKey().trim().isEmpty()) {
OpenAiConfigService.ValidationResult validationResult = openAiConfigService.validateApiKey(effectiveConfig.getApiKey());
validationPassed = validationResult.isValid();
validationMessage = validationResult.getMessage();
if (!validationPassed) {
log.warn("OpenAI API key validation failed during status check: {}", validationMessage);
}
}
Map<String, Object> response = new HashMap<>();
response.put("code", 0);
response.put("data", Map.of(
"configured", configured && validationPassed,
"hasDbConfig", hasDbConfig,
"hasYamlConfig", hasYamlConfig,
"validationPassed", validationPassed,
"validationMessage", validationMessage
));
response.put("msg", "Success");
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get OpenAI configuration status", e);
Map<String, Object> response = new HashMap<>();
response.put("code", 1);
response.put("msg", "Failed to get status: " + e.getMessage());
return ResponseEntity.ok(response);
}
}
}
@@ -1,25 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.dao;
/**
* Data Access Object interface for Message entities.
*/
public interface MessageDao {
}
@@ -15,11 +15,23 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.dao;
import org.apache.hertzbeat.ai.agent.entity.OpenAiConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
/**
* Data Access Object interface for Conversation entities.
* OpenAI Agent Configuration Dao
*/
public interface ConversationDao {
}
@Repository
public interface OpenAiConfigDao extends JpaRepository<OpenAiConfig, String>, JpaSpecificationExecutor<OpenAiConfig> {
/**
* Query by type
* @param type type
* @return Return the queried configuration information
*/
OpenAiConfig findByType(String type);
}
@@ -1,25 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.dao;
/**
* Data Access Object interface for UserPreference entities.
*/
public interface UserPreferenceDao {
}
@@ -0,0 +1,80 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.entity;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* OpenAI Agent Config Entity
*/
@Entity
@Table(name = "hzb_ai_agent_config")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "OpenAI Agent config entity")
@EntityListeners(AuditingEntityListener.class)
public class OpenAiConfig {
@Id
@Schema(title = "Config type: openai, primary key", description = "Config type: openai, primary key",
accessMode = READ_WRITE)
@NotBlank(message = "type can not null")
private String type;
@Schema(title = "Config content", description = "Config contentformat json", accessMode = READ_WRITE)
@Column(length = 8192)
private String content;
@Schema(title = "The creator of this record", example = "tom", accessMode = READ_ONLY)
@CreatedBy
private String creator;
@Schema(title = "This record was last modified by", example = "tom", accessMode = READ_ONLY)
@LastModifiedBy
private String modifier;
@Schema(title = "This record creation time (millisecond timestamp)", accessMode = READ_ONLY)
@CreatedDate
private LocalDateTime gmtCreate;
@Schema(title = "Record the latest modification time (timestamp in milliseconds)", accessMode = READ_ONLY)
@LastModifiedDate
private LocalDateTime gmtUpdate;
}
@@ -15,12 +15,17 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.event;
package org.apache.hertzbeat.ai.agent.controller;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEvent;
/**
* Controller for managing conversations.
* OpenAI configuration change event
*/
public class ConversationController {
public class OpenAiConfigChangeEvent extends ApplicationEvent {
}
public OpenAiConfigChangeEvent(ApplicationContext source) {
super(source);
}
}
@@ -19,13 +19,17 @@
package org.apache.hertzbeat.ai.agent.pojo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Chat request context for AI chat endpoint.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatRequestContext {
@@ -37,4 +41,9 @@ public class ChatRequestContext {
* Optional conversation ID for context
*/
private String conversationId;
}
/**
* Conversation history messages for context
*/
private List<MessageDto> conversationHistory;
}
@@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Chat response DTO for AI responses.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Chat response")
public class ChatResponseDto {
@Schema(description = "Conversation ID", example = "conv-123")
private String conversationId;
@Schema(description = "AI response message", example = "Here are your monitors...")
private String response;
@Schema(description = "User message ID", example = "msg-user-123")
private String userMessageId;
@Schema(description = "Assistant message ID", example = "msg-assistant-123")
private String assistantMessageId;
}
@@ -0,0 +1,53 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* Conversation DTO for AI chat conversations.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Conversation information")
public class ConversationDto {
@Schema(description = "Conversation ID", example = "conv-123")
private String conversationId;
@Schema(description = "Creation time")
private LocalDateTime createdAt;
@Schema(description = "Last updated time")
private LocalDateTime updatedAt;
@Schema(description = "Messages in this conversation")
private List<MessageDto> messages;
@Schema(description = "Message count")
private Integer messageCount;
}
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Message DTO for chat messages.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Chat message")
public class MessageDto {
@Schema(description = "Message ID", example = "msg-123")
private String messageId;
@Schema(description = "Conversation ID", example = "conv-123")
private String conversationId;
@Schema(description = "Message content", example = "List all monitors")
private String content;
@Schema(description = "Message role", example = "user", allowableValues = {"user", "assistant"})
private String role;
@Schema(description = "Message timestamp")
private LocalDateTime timestamp;
}
@@ -0,0 +1,47 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* OpenAI Configuration DTO - simplified to handle only API key
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "OpenAI configuration")
public class OpenAiConfigDto {
/**
* Whether to enable OpenAI, default is false
*/
@Schema(title = "Enable OpenAI", description = "Whether OpenAI is enabled", example = "true")
private boolean enable = false;
/**
* OpenAI API key
*/
@Schema(title = "API Key", description = "OpenAI API key", example = "sk-...")
@NotBlank(message = "API Key cannot be empty when enabled")
private String apiKey;
}
@@ -19,13 +19,18 @@
package org.apache.hertzbeat.ai.agent.service;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import reactor.core.publisher.Flux;
/**
* Service for interacting with LLM providers (like OpenAI, Anthropic, etc.)
*/
public interface ChatClientProviderService {
String complete(String message);
String streamChat(ChatRequestContext context);
}
/**
* Stream chat response from the LLM
*
* @param context Chat request context containing message and conversation history
* @return Flux of string chunks from the LLM response
*/
Flux<String> streamChat(ChatRequestContext context);
}
@@ -18,34 +18,34 @@
package org.apache.hertzbeat.ai.agent.service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatResponseDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.ConversationDto;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.Map;
/**
* Service for managing chat conversations and interactions with LLM providers.
*/
public interface ConversationService {
/**
* Create a new conversation
*
* @return Created conversation data
*/
ConversationDto createConversation();
/**
* Send a message and receive a streaming response
*
* @param message The user's message
* @param conversationId Optional conversation ID for continuing a chat
* @return SseEmitter for streaming the response
* @return Flux of ServerSentEvent for streaming the response
*/
SseEmitter streamChat(String message, String conversationId);
Flux<ServerSentEvent<ChatResponseDto>> streamChat(String message, String conversationId);
/**
* Send a message and get a complete response
*
* @param message The user's message
* @param conversationId Optional conversation ID for continuing a chat
* @return Response object containing the AI's response and conversation metadata
*/
Map<String, Object> chat(String message, String conversationId);
/**
* Get conversation history for a specific conversation
@@ -53,19 +53,28 @@ public interface ConversationService {
* @param conversationId Conversation ID
* @return Conversation data including messages
*/
Map<String, Object> getConversation(String conversationId);
ConversationDto getConversation(String conversationId);
/**
* Get all conversations for the current user
*
* @return List of conversations
*/
List<Map<String, Object>> getAllConversations();
List<ConversationDto> getAllConversations();
/**
* Delete a conversation
*
* @param conversationId Conversation ID to delete
* @return true if deleted, false if conversation not found
*/
void deleteConversation(String conversationId);
}
boolean deleteConversation(String conversationId);
/**
* Check if a conversation exists
*
* @param conversationId Conversation ID to check
* @return true if conversation exists, false otherwise
*/
boolean conversationExists(String conversationId);
}
@@ -0,0 +1,93 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
/**
* OpenAI Configuration Service
* Consolidated service for OpenAI configuration, validation, and client factory management
*/
public interface OpenAiConfigService {
/**
* Save OpenAI configuration
* @param config OpenAI configuration
*/
void saveConfig(OpenAiConfigDto config);
/**
* Get OpenAI configuration
* @return OpenAI configuration
*/
OpenAiConfigDto getConfig();
/**
* Check if OpenAI is properly configured
* @return true if configured and enabled
*/
boolean isConfigured();
/**
* Get effective OpenAI configuration (DB first, then YAML fallback)
* @return effective configuration or null if not configured
*/
OpenAiConfigDto getEffectiveConfig();
/**
* Validate OpenAI API key by calling the OpenAI API
* @param apiKey the API key to validate
* @return validation result with success status and message
*/
ValidationResult validateApiKey(String apiKey);
/**
* Force reload of OpenAI configuration cache
* This method is typically called when configuration changes
*/
void reloadConfig();
/**
* Validation result class
*/
class ValidationResult {
private final boolean valid;
private final String message;
private ValidationResult(boolean valid, String message) {
this.valid = valid;
this.message = message;
}
public static ValidationResult success(String message) {
return new ValidationResult(true, message);
}
public static ValidationResult failure(String message) {
return new ValidationResult(false, message);
}
public boolean isValid() {
return valid;
}
public String getMessage() {
return message;
}
}
}
@@ -1,30 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service.impl;
import org.apache.hertzbeat.ai.agent.service.AgentService;
import org.springframework.stereotype.Service;
/**
* Implementation of the AgentService interface.
* This service provides functionality for handling AI agent operations.
*/
@Service
public class AgentServiceImpl implements AgentService {
}
@@ -18,20 +18,30 @@
package org.apache.hertzbeat.ai.agent.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.config.PromptProvider;
import org.apache.hertzbeat.ai.agent.pojo.dto.MessageDto;
import org.apache.hertzbeat.ai.agent.service.ChatClientProviderService;
import org.springframework.stereotype.Service;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
/**
* Implementation of the {@link ChatClientProviderService}.
* Provides functionality to interact with the ChatClient for handling chat
* messages.
*/
@Slf4j
@Service
public class ChatClientProviderServiceImpl implements ChatClientProviderService {
@@ -42,11 +52,10 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
private ToolCallbackProvider toolCallbackProvider;
@Autowired
public ChatClientProviderServiceImpl(@Qualifier("openAiChatClient") ChatClient openAiChatClient) {
public ChatClientProviderServiceImpl(ChatClient openAiChatClient) {
this.chatClient = openAiChatClient;
}
@Override
public String complete(String message) {
return this.chatClient.prompt()
.user(message)
@@ -55,16 +64,38 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
}
@Override
public String streamChat(ChatRequestContext context) {
public Flux<String> streamChat(ChatRequestContext context) {
try {
return this.chatClient.prompt(PromptProvider.HERTZBEAT_MONITORING_PROMPT)
.user(context.getMessage())
.toolCallbacks(toolCallbackProvider)
.call()
.content();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
List<Message> messages = new ArrayList<>();
// Add conversation history if available
if (context.getConversationHistory() != null && !context.getConversationHistory().isEmpty()) {
for (MessageDto historyMessage : context.getConversationHistory()) {
if ("user".equals(historyMessage.getRole())) {
messages.add(new UserMessage(historyMessage.getContent()));
} else if ("assistant".equals(historyMessage.getRole())) {
messages.add(new AssistantMessage(historyMessage.getContent()));
}
}
}
messages.add(new UserMessage(context.getMessage()));
log.info("Starting streaming chat for conversation: {}", context.getConversationId());
return this.chatClient.prompt()
.messages(messages)
.system(PromptProvider.HERTZBEAT_MONITORING_PROMPT)
.toolCallbacks(toolCallbackProvider)
.stream()
.content()
.doOnNext(chunk -> log.debug("Received chunk: {}", chunk))
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", context.getConversationId()))
.doOnError(error -> log.error("Error in streaming chat: {}", error.getMessage(), error));
} catch (Exception e) {
log.error("Error setting up streaming chat: {}", e.getMessage(), e);
return Flux.error(e);
}
}
}
}
@@ -15,15 +15,262 @@
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.agent.pojo.dto.ChatResponseDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.ConversationDto;
import org.apache.hertzbeat.ai.agent.pojo.dto.MessageDto;
import org.apache.hertzbeat.ai.agent.service.ChatClientProviderService;
import org.apache.hertzbeat.ai.agent.service.ConversationService;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Implementation of the ConversationService interface for managing chat conversations.
*/
@Slf4j
@Service
public class ConversationServiceImpl {
public class ConversationServiceImpl implements ConversationService {
}
private final Map<String, Map<String, Object>> conversations = new ConcurrentHashMap<>();
private final Map<String, List<Map<String, Object>>> conversationMessages = new ConcurrentHashMap<>();
@Autowired
private ChatClientProviderService chatClientProviderService;
@Autowired
private OpenAiConfigService openAiConfigService;
@Override
public ConversationDto createConversation() {
String conversationId = createNewConversation();
return getConversation(conversationId);
}
@Override
public Flux<ServerSentEvent<ChatResponseDto>> streamChat(String message, String conversationId) {
// Validate conversation exists
if (!conversationExists(conversationId)) {
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(conversationId)
.response("Error: Conversation not found: " + conversationId)
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
.build());
}
// Check if OpenAI is properly configured
if (!openAiConfigService.isConfigured()) {
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(conversationId)
.response("OpenAI is not configured. Please configure your OpenAI API key in the settings or application.yml file.")
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
.build());
}
log.info("Starting streaming conversation: {}", conversationId);
// Add user message to conversation
String userMessageId = addMessageToConversation(conversationId, message, "user");
// Get conversation history for context
List<Map<String, Object>> messagesList = conversationMessages.get(conversationId);
List<MessageDto> conversationHistory = new ArrayList<>();
if (messagesList != null && messagesList.size() > 1) {
// Get all messages except the last one (which is the current user message we just added)
for (int i = 0; i < messagesList.size() - 1; i++) {
Map<String, Object> msgMap = messagesList.get(i);
conversationHistory.add(mapToMessageDto(msgMap));
}
}
ChatRequestContext context = ChatRequestContext.builder()
.message(message)
.conversationId(conversationId)
.conversationHistory(conversationHistory)
.build();
// Stream response from AI service
StringBuilder fullResponse = new StringBuilder();
return chatClientProviderService.streamChat(context)
.map(chunk -> {
fullResponse.append(chunk);
ChatResponseDto responseDto = ChatResponseDto.builder()
.conversationId(conversationId)
.response(chunk)
.userMessageId(userMessageId)
.build();
return ServerSentEvent.builder(responseDto)
.event("message")
.build();
})
.concatWith(Flux.defer(() -> {
// Add the complete AI response to conversation
String assistantMessageId = addMessageToConversation(conversationId, fullResponse.toString(), "assistant");
ChatResponseDto finalResponse = ChatResponseDto.builder()
.conversationId(conversationId)
.response("")
.userMessageId(userMessageId)
.assistantMessageId(assistantMessageId)
.build();
return Flux.just(ServerSentEvent.builder(finalResponse)
.event("complete")
.build());
}))
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", conversationId))
.doOnError(error -> log.error("Error in streaming chat for conversation {}: {}", conversationId, error.getMessage(), error))
.onErrorResume(error -> {
ChatResponseDto errorResponse = ChatResponseDto.builder()
.conversationId(conversationId)
.response("An error occurred: " + error.getMessage())
.userMessageId(userMessageId)
.build();
return Flux.just(ServerSentEvent.builder(errorResponse)
.event("error")
.build());
});
}
@Override
public ConversationDto getConversation(String conversationId) {
if (conversationId == null || conversationId.isEmpty()) {
return null;
}
Map<String, Object> conversation = conversations.get(conversationId);
if (conversation == null) {
return null;
}
List<Map<String, Object>> messagesList = conversationMessages.get(conversationId);
List<MessageDto> messages = messagesList != null
? messagesList.stream().map(this::mapToMessageDto).collect(Collectors.toList()) :
new ArrayList<>();
return ConversationDto.builder()
.conversationId((String) conversation.get("conversationId"))
.createdAt((LocalDateTime) conversation.get("createdAt"))
.updatedAt((LocalDateTime) conversation.get("updatedAt"))
.messages(messages)
.build();
}
@Override
public List<ConversationDto> getAllConversations() {
List<ConversationDto> result = new ArrayList<>();
for (Map.Entry<String, Map<String, Object>> entry : conversations.entrySet()) {
Map<String, Object> conv = entry.getValue();
List<Map<String, Object>> messages = conversationMessages.get(entry.getKey());
ConversationDto dto = ConversationDto.builder()
.conversationId((String) conv.get("conversationId"))
.createdAt((LocalDateTime) conv.get("createdAt"))
.updatedAt((LocalDateTime) conv.get("updatedAt"))
.messages(new ArrayList<>()) // Don't include messages in list view for performance
.build();
result.add(dto);
}
result.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt()));
return result;
}
@Override
public boolean deleteConversation(String conversationId) {
if (conversationId == null || conversationId.isEmpty()) {
return false;
}
boolean existed = conversations.containsKey(conversationId);
if (existed) {
conversations.remove(conversationId);
conversationMessages.remove(conversationId);
log.info("Deleted conversation: {}", conversationId);
}
return existed;
}
@Override
public boolean conversationExists(String conversationId) {
return conversationId != null && !conversationId.isEmpty() && conversations.containsKey(conversationId);
}
private String createNewConversation() {
String conversationId = "conv-" + UUID.randomUUID().toString().substring(0, 8);
LocalDateTime now = LocalDateTime.now();
Map<String, Object> conversation = new HashMap<>();
conversation.put("conversationId", conversationId);
conversation.put("createdAt", now);
conversation.put("updatedAt", now);
conversations.put(conversationId, conversation);
conversationMessages.put(conversationId, new ArrayList<>());
log.info("Created new conversation: {}", conversationId);
return conversationId;
}
private MessageDto mapToMessageDto(Map<String, Object> messageMap) {
return MessageDto.builder()
.messageId((String) messageMap.get("messageId"))
.conversationId((String) messageMap.get("conversationId"))
.content((String) messageMap.get("content"))
.role((String) messageMap.get("role"))
.timestamp((LocalDateTime) messageMap.get("timestamp"))
.build();
}
private String addMessageToConversation(String conversationId, String content, String role) {
List<Map<String, Object>> messages = conversationMessages.computeIfAbsent(conversationId, k -> new ArrayList<>());
String messageId = "msg-" + UUID.randomUUID().toString().substring(0, 8);
Map<String, Object> message = new HashMap<>();
message.put("messageId", messageId);
message.put("conversationId", conversationId);
message.put("content", content);
message.put("role", role);
message.put("timestamp", LocalDateTime.now());
messages.add(message);
// Update conversation timestamp
Map<String, Object> conversation = conversations.get(conversationId);
if (conversation != null) {
conversation.put("updatedAt", LocalDateTime.now());
// Auto-generate title from first user message
if ("user".equals(role) && messages.stream().filter(m -> "user".equals(m.get("role"))).count() == 1) {
String title = content.length() > 30 ? content.substring(0, 27) + "..." : content;
conversation.put("title", title);
}
}
return messageId;
}
}
@@ -0,0 +1,200 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.agent.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.config.OpenAiYamlConfig;
import org.apache.hertzbeat.ai.agent.dao.OpenAiConfigDao;
import org.apache.hertzbeat.ai.agent.entity.OpenAiConfig;
import org.apache.hertzbeat.ai.agent.event.OpenAiConfigChangeEvent;
import org.apache.hertzbeat.ai.agent.pojo.dto.OpenAiConfigDto;
import org.apache.hertzbeat.ai.agent.service.OpenAiConfigService;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
/**
* OpenAI Configuration Service Implementation
* Consolidated service for OpenAI configuration, validation, and client factory management
*/
@Slf4j
@Service
public class OpenAiConfigServiceImpl implements OpenAiConfigService {
private static final String CONFIG_TYPE = "openai";
private static final String OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models";
private final OpenAiConfigDao openAiConfigDao;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final OpenAiYamlConfig yamlConfig;
private final RestTemplate restTemplate;
// Client factory cache
private volatile OpenAiConfigDto currentConfig;
public OpenAiConfigServiceImpl(OpenAiConfigDao openAiConfigDao,
ObjectMapper objectMapper,
ApplicationContext applicationContext,
OpenAiYamlConfig yamlConfig) {
this.openAiConfigDao = openAiConfigDao;
this.objectMapper = objectMapper;
this.applicationContext = applicationContext;
this.yamlConfig = yamlConfig;
this.restTemplate = new RestTemplate();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveConfig(OpenAiConfigDto config) {
try {
String contentJson = objectMapper.writeValueAsString(config);
OpenAiConfig openAiConfig = OpenAiConfig.builder()
.type(CONFIG_TYPE)
.content(contentJson)
.build();
openAiConfigDao.save(openAiConfig);
log.info("OpenAI configuration saved successfully");
applicationContext.publishEvent(new OpenAiConfigChangeEvent(applicationContext));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to save OpenAI configuration: " + e.getMessage());
}
}
@Override
public OpenAiConfigDto getConfig() {
OpenAiConfig config = openAiConfigDao.findByType(CONFIG_TYPE);
if (config == null || !StringUtils.hasText(config.getContent())) {
return null;
}
try {
return objectMapper.readValue(config.getContent(), OpenAiConfigDto.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse OpenAI configuration: " + e.getMessage());
}
}
@Override
public boolean isConfigured() {
OpenAiConfigDto effective = getEffectiveConfig();
return effective != null && effective.isEnable() && StringUtils.hasText(effective.getApiKey());
}
@Override
public OpenAiConfigDto getEffectiveConfig() {
OpenAiConfigDto dbConfig = getConfig();
if (dbConfig != null && dbConfig.isEnable() && StringUtils.hasText(dbConfig.getApiKey())) {
log.debug("Using database OpenAI configuration");
return dbConfig;
}
if (yamlConfig != null && yamlConfig.isEnable() && StringUtils.hasText(yamlConfig.getApiKey())) {
log.debug("Using YAML OpenAI configuration from spring.ai.openai.api-key");
OpenAiConfigDto yamlDto = new OpenAiConfigDto();
yamlDto.setEnable(true);
yamlDto.setApiKey(yamlConfig.getApiKey());
return yamlDto;
}
log.debug("No valid OpenAI configuration found");
return null;
}
@Override
public ValidationResult validateApiKey(String apiKey) {
if (!StringUtils.hasText(apiKey)) {
return ValidationResult.failure("API key cannot be empty");
}
if (!apiKey.startsWith("sk-")) {
return ValidationResult.failure("Invalid API key format. OpenAI API keys should start with 'sk-'");
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.set("Content-Type", "application/json");
HttpEntity<String> entity = new HttpEntity<>(headers);
log.debug("Validating OpenAI API key by calling models endpoint");
ResponseEntity<String> response = restTemplate.exchange(
OPENAI_MODELS_ENDPOINT,
HttpMethod.GET,
entity,
String.class
);
if (response.getStatusCode() == HttpStatus.OK) {
log.info("OpenAI API key validation successful");
return ValidationResult.success("API key is valid");
} else {
log.warn("OpenAI API key validation failed with status: {}", response.getStatusCode());
return ValidationResult.failure("API key validation failed: " + response.getStatusCode());
}
} catch (Exception e) {
log.error("Error validating OpenAI API key", e);
String errorMessage = e.getMessage();
// Parse common error messages
if (errorMessage.contains("401")) {
return ValidationResult.failure("Invalid API key - authentication failed");
} else if (errorMessage.contains("403")) {
return ValidationResult.failure("API key does not have permission to access models");
} else if (errorMessage.contains("429")) {
return ValidationResult.failure("Rate limit exceeded - please try again later");
} else if (errorMessage.contains("timeout") || errorMessage.contains("connect")) {
return ValidationResult.failure("Network error - unable to connect to OpenAI API");
} else {
return ValidationResult.failure("API key validation failed: " + errorMessage);
}
}
}
@Override
public void reloadConfig() {
synchronized (this) {
currentConfig = null; // Force reload
}
}
/**
* OpenAI configuration change event listener
*/
@EventListener(OpenAiConfigChangeEvent.class)
public void onOpenAiConfigChange(OpenAiConfigChangeEvent event) {
log.info("[OpenAiConfigService] OpenAI configuration change event received");
reloadConfig();
}
}
@@ -18,8 +18,6 @@
package org.apache.hertzbeat.ai.agent.tools;
import org.springframework.ai.chat.model.ToolContext;
import java.util.List;
/**
@@ -27,13 +25,43 @@ import java.util.List;
*/
public interface MonitorTools {
String addMonitor(String name, ToolContext context);
/**
* Add a new monitor with comprehensive configuration
*
* @param name Monitor name
* @param app Monitor type/application (e.g., 'linux', 'mysql', 'http')
* @param host Target host (IP address or domain name)
* @param port Target port (optional, depends on monitor type)
* @param intervals Collection interval in seconds (default: 600)
* @param username Username for authentication (optional)
* @param password Password for authentication (optional)
* @param description Monitor description (optional)
* @return Result message with monitor ID if successful
*/
String addMonitor(
String name,
String app,
String host,
Integer port,
Integer intervals,
String username,
String password,
String description
);
/**
* List all available monitor types that can be added
*
* @param language Language code for localized names (e.g., 'en-US', 'zh-CN')
* @return Formatted string list of available monitor types with descriptions
*/
String listMonitorTypes(String language);
/**
* Query monitor information with flexible filtering and pagination.
* Supports filtering by monitor IDs, type, status, host, labels, sorting, and
* pagination.
* Returns results as plain JSON.
* Returns results as plain JSON string for AI tool.
*/
String listMonitors(
List<Long> ids,
@@ -44,7 +72,15 @@ public interface MonitorTools {
String sort,
String order,
Integer pageIndex,
Integer pageSize,
ToolContext context);
Integer pageSize);
}
/**
* Get parameter definitions required for a specific monitor type
*
* @param app Monitor type/application name (e.g., 'linux', 'mysql', 'redis')
* @return Formatted string with parameter definitions including field names, types, and requirements
*/
String getMonitorParamDefines(String app);
}
@@ -21,7 +21,6 @@ import com.usthe.sureness.subject.SubjectSum;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.agent.adapters.MonitorServiceAdapter;
import org.apache.hertzbeat.ai.agent.config.McpContextHolder;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
@@ -29,8 +28,13 @@ import org.apache.hertzbeat.ai.agent.tools.MonitorTools;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.manager.Param;
import org.apache.hertzbeat.common.entity.manager.ParamDefine;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Implementation of Monitoring Tools functionality
@@ -47,13 +51,15 @@ public class MonitorToolsImpl implements MonitorTools {
* Tool to query monitor information with flexible filtering and pagination.
* Supports filtering by monitor IDs, type, status, host, labels, sorting, and
* pagination.
* Returns monitor names as string.
* Returns detailed monitor information including ID, name, type, host, and status.
*/
@Override
@Tool(name = "list_monitors", returnDirect = true, description = """
@Tool(name = "list_monitors", description = """
Query monitor information with flexible filtering and pagination.
Supports filtering by monitor IDs, type, status, host, labels, sorting, and pagination.
Returns results as String. When no parameters are available, pass the default value as mentioned below. If the user doesn't provide any specific parameter, the default value will be used.
Returns detailed results including monitor ID, name, type, host, and status for easy identification and management.
Show the long monitor id in the brackets
When no parameters are available, pass the default value as mentioned below. If the user doesn't provide any specific parameter, the default value will be used.
""")
public String listMonitors(
@ToolParam(description = "List of monitor IDs to filter (default: empty list)", required = false) List<Long> ids,
@@ -64,24 +70,301 @@ public class MonitorToolsImpl implements MonitorTools {
@ToolParam(description = "Sort field, e.g., 'name' (default: gmtCreate)", required = false) String sort,
@ToolParam(description = "Sort order, 'asc' or 'desc' (default: desc)", required = false) String order,
@ToolParam(description = "Page index (default: 0)", required = false) Integer pageIndex,
@ToolParam(description = "Page size (default: 8)", required = false) Integer pageSize,
ToolContext context) {
@ToolParam(description = "Page size (default: 8)", required = false) Integer pageSize) {
try {
Page<Monitor> result = monitorServiceAdapter.getMonitors(ids, app, search, status, sort, order, pageIndex, pageSize, labels);
log.debug("MonitorServiceAdapter.getMonitors result: {}", result);
return result.getContent().stream().map(Monitor::getName).toList().toString();
// Format response to include both ID and name for better usability
StringBuilder response = new StringBuilder();
response.append("Found ").append(result.getContent().size()).append(" monitors:\n\n");
for (Monitor monitor : result.getContent()) {
log.info(String.valueOf(monitor.getId()));
response.append("ID: ").append(monitor.getId())
.append(" | Name: ").append(monitor.getName())
.append(" | Type: ").append(monitor.getApp())
.append(" | Host: ").append(monitor.getHost())
.append(" | Status: ").append(getStatusText(monitor.getStatus()))
.append("\n");
}
if (result.getContent().isEmpty()) {
response.append("No monitors found matching the specified criteria.");
}
return response.toString();
} catch (Exception e) {
return "error is" + e.getMessage();
return "Error retrieving monitors: " + e.getMessage();
}
}
@Override
@Tool(name = "add_monitor", description = """
Add a new monitor to HertzBeat with comprehensive configuration.
This tool creates a monitor for various types like linux, mysql, http, redis, etc.
validate the the monitor type using the list_monitor_types tool.
ALWAYS Ask the user to give all the the parameter definitions for the asked monitor type using the get_monitor_param_defines tool.
""")
public String addMonitor(
@ToolParam(description = "Monitor name (required)", required = true) String name,
@ToolParam(description = "Monitor type/application: linux, mysql, http, redis, postgresql, etc.", required = true) String app,
@ToolParam(description = "Target host: IP address or domain name", required = true) String host,
@ToolParam(description = "Target port (optional, depends on monitor type)", required = false) Integer port,
@ToolParam(description = "Collection interval in seconds (default: 600)", required = false) Integer intervals,
@ToolParam(description = "Username for authentication (optional)", required = false) String username,
@ToolParam(description = "Password for authentication (optional)", required = false) String password,
@ToolParam(description = "Monitor description (optional)", required = false) String description) {
try {
log.info("Adding monitor: name={}, app={}, host={}", name, app, host);
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current subject in add_monitor tool: {}", subjectSum);
// Validate required parameters
if (name == null || name.trim().isEmpty()) {
return "Error: Monitor name is required";
}
if (app == null || app.trim().isEmpty()) {
return "Error: Monitor type/application is required";
}
if (host == null || host.trim().isEmpty()) {
return "Error: Host is required";
}
// Set default values
if (intervals == null || intervals < 10) {
intervals = 600; // Default 10 minutes
}
// Create Monitor entity
Monitor monitor = Monitor.builder()
.name(name.trim())
.app(app.toLowerCase().trim())
.host(host.trim())
.intervals(intervals)
.status((byte) 1) // Status: Up
.type((byte) 0) // Type: Normal
.description(description != null ? description.trim() : "")
.build();
// Create parameters list
List<Param> params = new ArrayList<>();
// Add host parameter (always required)
params.add(Param.builder()
.field("host")
.paramValue(host.trim())
.type((byte) 0)
.build());
// Add port parameter if provided
if (port != null && port > 0) {
params.add(Param.builder()
.field("port")
.paramValue(port.toString())
.type((byte) 0)
.build());
}
// Add authentication parameters if provided
if (username != null && !username.trim().isEmpty()) {
params.add(Param.builder()
.field("username")
.paramValue(username.trim())
.type((byte) 1) // Type: Password
.build());
}
if (password != null && !password.trim().isEmpty()) {
params.add(Param.builder()
.field("password")
.paramValue(password.trim())
.type((byte) 1) // Type: Password
.build());
}
// Add timeout parameter (default)
params.add(Param.builder()
.field("timeout")
.paramValue("6000")
.type((byte) 0)
.build());
// Call the adapter to add the monitor
Long monitorId = monitorServiceAdapter.addMonitor(monitor, params, null);
log.info("Successfully added monitor '{}' with ID: {}", name, monitorId);
return String.format("Successfully added monitor '%s' with ID: %d. Monitor type: %s, Host: %s, Interval: %d seconds",
name, monitorId, app, host, intervals);
} catch (Exception e) {
log.error("Failed to add monitor '{}': {}", name, e.getMessage(), e);
return "Error adding monitor '" + name + "': " + e.getMessage();
}
}
@Override
@Tool(name = "add_monitor", description = "Add a new monitor")
public String addMonitor(@ToolParam(description = "Name of the monitor") String name, ToolContext context) {
log.debug("Adding monitor with name: {}", name);
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current subject in tool: {}", subjectSum);
return "Monitor added: " + name;
@Tool(name = "list_monitor_types", description = """
List all available monitor types that can be added to HerzBeat.
This tool shows all supported monitor types with their display names.
Use this to see what types of monitors you can create with the add_monitor tool.
""")
public String listMonitorTypes(
@ToolParam(description = "Language code for localized names (en-US, zh-CN, etc.). Default: en-US", required = false) String language) {
try {
log.info("Listing available monitor types for language: {}", language);
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current subject in list_monitor_types tool: {}", subjectSum);
// Set default language if not provided
if (language == null || language.trim().isEmpty()) {
language = "en-US";
}
// Get available monitor types from adapter
Map<String, String> monitorTypes = monitorServiceAdapter.getAvailableMonitorTypes(language);
if (monitorTypes == null || monitorTypes.isEmpty()) {
return "No monitor types are currently available.";
}
// Format the response as a nice list
StringBuilder response = new StringBuilder();
response.append("Available Monitor Types (Total: ").append(monitorTypes.size()).append("):\n\n");
// Sort monitor types alphabetically by key
List<Map.Entry<String, String>> sortedTypes = monitorTypes.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.collect(Collectors.toList());
for (Map.Entry<String, String> entry : sortedTypes) {
String typeKey = entry.getKey();
String displayName = entry.getValue();
response.append("").append(typeKey)
.append(" - ").append(displayName)
.append("\n");
}
response.append("\nTo add a monitor, use the add_monitor tool with one of these types as the 'app' parameter.");
response.append("\nExample: add_monitor(name='my-server', app='linux', host='192.168.1.100')");
log.info("Successfully listed {} monitor types", monitorTypes.size());
return response.toString();
} catch (Exception e) {
log.error("Failed to list monitor types: {}", e.getMessage(), e);
return "Error retrieving monitor types: " + e.getMessage();
}
}
}
@Override
@Tool(name = "get_monitor_param_defines", description = """
Get the parameter definitions required for a specific monitor type.
This tool shows what parameters are needed when adding a monitor of the specified type,
including field names, data types, validation rules, and whether they are required.
Use this before adding a monitor to understand what parameters you need to provide.
""")
public String getMonitorParamDefines(
@ToolParam(description = "Monitor type/application name (e.g., 'linux', 'mysql', 'redis')", required = true) String app) {
try {
log.info("Getting parameter definitions for monitor type: {}", app);
SubjectSum subjectSum = McpContextHolder.getSubject();
log.debug("Current subject in get_monitor_param_defines tool: {}", subjectSum);
// Validate required parameter
if (app == null || app.trim().isEmpty()) {
return "Error: Monitor type/application parameter is required";
}
// Get parameter definitions from adapter
List<ParamDefine> paramDefines = monitorServiceAdapter.getMonitorParamDefines(app);
if (paramDefines == null || paramDefines.isEmpty()) {
return String.format("No parameter definitions found for monitor type '%s'. "
+ "This monitor type may not exist or may not require additional parameters.", app);
}
// Format the response
StringBuilder response = new StringBuilder();
response.append(String.format("Parameter Definitions for Monitor Type '%s' (Total: %d):\n\n",
app, paramDefines.size()));
for (ParamDefine paramDefine : paramDefines) {
response.append("• Field: ").append(paramDefine.getField()).append("\n");
// Add display name if available
if (paramDefine.getName() != null && !paramDefine.getName().toString().trim().isEmpty()) {
response.append(" Name: ").append(paramDefine.getName()).append("\n");
}
// Add type
if (paramDefine.getType() != null && !paramDefine.getType().trim().isEmpty()) {
response.append(" Type: ").append(paramDefine.getType()).append("\n");
}
// Add required status
response.append(" Required: ").append(paramDefine.isRequired() ? "Yes" : "No").append("\n");
// Add default value if present
if (paramDefine.getDefaultValue() != null && !paramDefine.getDefaultValue().trim().isEmpty()) {
response.append(" Default: ").append(paramDefine.getDefaultValue()).append("\n");
}
// Add validation range if present
if (paramDefine.getRange() != null && !paramDefine.getRange().trim().isEmpty()) {
response.append(" Range: ").append(paramDefine.getRange()).append("\n");
}
// Add limit if present
if (paramDefine.getLimit() != null) {
response.append(" Limit: ").append(paramDefine.getLimit()).append("\n");
}
// Add placeholder text if present
if (paramDefine.getPlaceholder() != null && !paramDefine.getPlaceholder().trim().isEmpty()) {
response.append(" Placeholder: ").append(paramDefine.getPlaceholder()).append("\n");
}
response.append("\n");
}
response.append("To add a monitor of this type, use the add_monitor tool with these parameters.\n");
response.append(String.format("Example: add_monitor(name='my-monitor', app='%s', host='your-host', ...)", app));
log.info("Successfully retrieved {} parameter definitions for monitor type: {}", paramDefines.size(), app);
return response.toString();
} catch (Exception e) {
log.error("Failed to get parameter definitions for monitor type '{}': {}", app, e.getMessage(), e);
return "Error retrieving parameter definitions for monitor type '" + app + "': " + e.getMessage();
}
}
/**
* Helper method to convert monitor status byte to readable text
* @param status The status byte from monitor
* @return Human-readable status text
*/
private String getStatusText(Byte status) {
if (status == null) {
return "Unknown";
}
switch (status) {
case 0:
return "Paused";
case 1:
return "Online";
case 2:
return "Offline";
case 3:
return "Unreachable";
default:
return "Unknown (" + status + ")";
}
}
}
@@ -68,6 +68,9 @@ resourceRole:
- /api/bulletin/**===delete===[admin]
- /api/sse/**===get===[admin,user]
- /api/sse/**===post===[admin,user]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin,user]
# config the resource restful api that need bypass auth protection
# rule: api===method
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
+254
View File
@@ -0,0 +1,254 @@
---
id: ai_agent_chat
title: AI Agent Chat User Guide
sidebar_label: AI Agent Chat
keywords: [AI, Chat, Agent, Monitoring, Assistant, OpenAI]
---
> HertzBeat AI Agent Chat is an intelligent monitoring assistant that helps you manage monitors, configure alerts, and optimize your infrastructure monitoring through natural language conversation.
## Overview
The AI Agent Chat feature provides an interactive chat interface where you can:
- 🔍 List and manage your existing monitors
- Add new monitors for websites, APIs, databases, and services
- 📊 Get detailed information about available monitor types and their parameters
- ⚡ Check monitor status and troubleshoot monitoring issues
## Prerequisites
Before using the AI Agent Chat, ensure:
1 **OpenAI Configuration**: Valid OpenAI API key must be configured
2 **Database Connection**: HertzBeat database must be accessible for monitor operations
## Configuration
### OpenAI API Key Setup
The AI Agent Chat uses OpenAI's GPT models. You need to configure an OpenAI API key in one of two ways:
#### Method 1: Database Configuration via UI (Recommended)
1. Navigate to the AI Agent Chat interface
2. If no API key is configured, you'll see a configuration dialog
3. Enter your OpenAI API key
4. Click **Save Configuration**
#### Method 2: YAML Configuration
Add to your `application.yml`:
```yaml
spring:
ai:
openai:
api-key: sk-your-openai-api-key-here
```
### Configuration Priority
- **Database configuration** takes priority over YAML configuration
- Configuration changes are applied immediately without restart
- Invalid API keys are automatically validated before saving
## Using the AI Agent Chat
### Accessing the Chat Interface
1. Navigate to the **AI Agent Chat** section in HertzBeat web interface
2. You'll see the HertzBeat Monitoring Assistant welcome screen
3. Start typing your questions in the chat input field
### Sample Conversations
#### Adding a New Monitor
```text
You: Add a new HTTP monitor for my website https://example.com
Assistant: I'll help you add an HTTP monitor for https://example.com.
Let me create that monitor with the following configuration:
- Name: example.com Website Monitor
- Type: HTTP
- Host: example.com
- Port: 443 (HTTPS)
- Path: /
- Interval: 60 seconds
Monitor created successfully with ID: 12345
```
#### Listing Existing Monitors
```text
You: Show me all my current monitors
Assistant: Here are your current monitors:
1. **Linux Server** (ID: 1001)
- Status: ✅ Online
- Host: 192.168.1.100
- Last Check: 2 minutes ago
2. **MySQL Database** (ID: 1002)
- Status: ⚠️ Warning
- Host: db.example.com
- Last Check: 30 seconds ago
3. **Website Monitor** (ID: 1003)
- Status: ✅ Online
- Host: https://example.com
- Last Check: 1 minute ago
Would you like details on any specific monitor?
```
#### Getting Monitor Type Information
```text
You: What parameters do I need to monitor a PostgreSQL database?
Assistant: To monitor a PostgreSQL database, you'll need these parameters:
**Required Parameters:**
- **Host**: Database server IP/hostname
- **Port**: PostgreSQL port (default: 5432)
- **Database**: Database name to connect to
- **Username**: Database username
- **Password**: Database password
**Optional Parameters:**
- **Timeout**: Connection timeout (default: 6000ms)
- **Query Timeout**: SQL query timeout (default: 3000ms)
- **SSL**: Enable SSL connection (default: false)
Would you like me to help you add a PostgreSQL monitor?
```
### Chat Features
#### Message History
- All conversations are automatically saved
- Access previous conversations from the sidebar
- Search through conversation history
- Export conversation logs
#### Real-time Responses
- Streaming responses for immediate feedback
- Typing indicators show when the assistant is processing
- Cancel ongoing requests if needed
#### Conversation Management
- Create new conversations for different topics
- Rename conversations for better organization
- Delete old conversations to keep things clean
Note: Conversations are not saved in the database
## Troubleshooting
### Common Issues
#### Chat Interface Not Loading
**Symptoms**: Chat interface shows loading spinner indefinitely
**Solutions**:
1. Check browser console for JavaScript errors
2. Ensure network connectivity to HertzBeat server
#### "Service Unavailable" Message
**Symptoms**: Chat shows "HertzBeat AI monitoring service unavailable"
**Solutions**:
1.Verify OpenAI API key configuration
2.Check application logs for errors
3.Ensure database connectivity
#### Invalid API Key Error
**Symptoms**: Configuration dialog shows "Invalid API key" error
**Solutions**:
1. Verify your OpenAI API key starts with `sk-`
2. Check API key has sufficient credits/quota
3. Test API key directly with OpenAI API
4. Ensure no extra spaces in the API key
#### Monitor Creation Failures
**Symptoms**: AI suggests monitor configuration but creation fails
**Solutions**:
1. Verify you have permissions to create monitors
2. Check if monitor with same name already exists
3. Ensure target host/service is accessible
4. Review monitor parameter validation errors
### Debug Mode
Enable debug logging by setting log level to DEBUG for:
```yaml
logging:
level:
org.apache.hertzbeat.ai.agent: DEBUG
```
## Best Practices
### Effective Chat Usage
1. **Be Specific**: "Add HTTP monitor for api.example.com port 8080" vs "add a monitor"
2. **Provide Context**: Mention if you want production vs test monitors
3. **Ask Follow-ups**: Request configuration details if needed
4. **Use Natural Language**: The AI understands conversational requests
### Monitor Management
1. **Naming Convention**: Use descriptive monitor names
2. **Documentation**: Ask the AI to document complex configurations
### Security Considerations
1. **API Key Security**: Store OpenAI API keys securely
2. **Access Control**: Limit AI chat access to authorized users
3. **Data Privacy**: Be mindful of sensitive information in chat logs
4. **Network Security**: Ensure secure connections to OpenAI API
## Advanced Features
### Bulk Operations
```text
You: Add HTTP monitors for all services in my staging environment:
- api-staging.example.com:8080
- web-staging.example.com:80
- admin-staging.example.com:3000
```
### Integration Suggestions
```text
You: What's the best way to monitor a microservices architecture with 20+ services?
```
## Limitations
- Requires active internet connection for OpenAI API
- OpenAI API usage incurs costs based on token consumption
- Complex multi-step operations may require multiple interactions
- Some advanced configurations may need manual setup
- Rate limiting may apply based on OpenAI plan
## Support
For issues with AI Agent Chat:
1. Check this documentation first
2. Review application logs for errors
3. Test OpenAI API connectivity independently
4. Contact HertzBeat support with specific error messages
---
+4 -2
View File
@@ -63,9 +63,11 @@ After saving, reload MCP in Cursor or restart the editor.
### Tools available
- list_monitors: Returns the list of names of all configured monitors.
- **list_monitors**: Query monitor information with flexible filtering and pagination. Supports filtering by monitor IDs, type, status, host, labels, sorting, and pagination.
- **add_monitor**: Add a new monitor to HertzBeat with comprehensive configuration. Creates monitors for various types like linux, mysql, http, redis, etc.
- **list_monitor_types**: List all available monitor types that can be added to HertzBeat. Shows all supported monitor types with their display names.
- **get_monitor_param_defines**: Get the parameter definitions required for a specific monitor type. Shows what parameters are needed when adding a monitor.
More tools are coming soon to expand management and query capabilities.
### Notes
+1
View File
@@ -279,6 +279,7 @@
"help/grafana_dashboard",
"help/mcp_sse_server",
"help/collector",
"help/ai_agent_chat",
"help/ai_config",
"help/issue"
]
+37 -149
View File
@@ -328,174 +328,62 @@ global-footer {
min-height: 120px;
}
/* AI Assistant Chatbot styles */
.ai-chatbot-container {
/* AI Chat Button styles */
.ai-chat-button-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.ai-chatbot-button {
width: 50px;
height: 50px;
.ai-chat-button {
width: 56px;
height: 56px;
border-radius: 50%;
background-color: @primary-color; /* Use system theme color */
background-color: @primary-color;
color: white;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s;
transition: all 0.3s ease;
}
.ai-chatbot-button:hover {
.ai-chat-button:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Robot icon styles */
.robot-icon {
width: 30px;
height: 30px;
display: flex;
justify-content: center;
align-items: center;
.ai-chat-button:active {
transform: scale(0.95);
}
.robot-icon svg {
width: 30px;
height: 30px;
fill: white; /* Ensure SVG is white */
}
.ai-chatbot-window {
position: absolute;
bottom: 70px;
right: 0;
width: 350px;
height: 500px;
border-radius: 8px;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
overflow: hidden;
transition: all 0.3s;
}
.ai-chatbot-window.maximized {
width: 80vw;
height: 80vh;
bottom: 10vh;
right: 10vw;
}
.chatbot-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: @primary-color; /* Use system theme color */
color: white;
}
.chatbot-title {
font-weight: 500;
display: flex;
align-items: center;
}
.chatbot-controls {
display: flex;
gap: 12px;
}
.control-item {
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.3s;
}
.control-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background-color: #f5f5f5;
}
.message {
max-width: 80%;
padding: 8px 12px;
border-radius: 12px;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.user-message {
align-self: flex-end;
background-color: @primary-color; /* Use system theme color */
color: white;
}
.bot-message {
align-self: flex-start;
background-color: white;
color: #333;
border: 1px solid #e8e8e8;
}
.streaming-message {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
}
.message-content {
word-break: break-word;
white-space: pre-wrap;
}
.message-time {
font-size: 12px;
margin-top: 4px;
opacity: 0.7;
text-align: right;
}
.loading-message {
padding: 12px;
text-align: center;
}
.chatbot-input {
display: flex;
padding: 12px;
border-top: 1px solid #e8e8e8;
background-color: white;
}
.chatbot-input input {
flex: 1;
margin-right: 8px;
/* AI Chat Modal Styles */
:host ::ng-deep .ai-chat-modal {
.ant-modal {
border-radius: 16px !important;
overflow: hidden !important;
}
.ant-modal-content {
border-radius: 16px !important;
overflow: hidden !important;
}
.ant-modal-header {
border-radius: 16px 16px 0 0 !important;
margin: 0 !important;
padding: 16px !important;
}
.ant-modal-body {
border-radius: 0 0 16px 16px !important;
padding: 0 !important;
}
.ant-modal-close {
top: 8px !important;
right: 8px !important;
}
}
+10 -175
View File
@@ -3,11 +3,10 @@ import { I18NService } from '@core';
import { ALAIN_I18N_TOKEN, SettingsService, User } from '@delon/theme';
import { LayoutDefaultOptions } from '@delon/theme/layout-default';
import { environment } from '@env/environment';
import { Observable, Subject, of } from 'rxjs';
import { delay, tap, finalize, catchError, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { CONSTANTS } from '../../shared/constants';
import { AiBotService, ChatMessage } from '../../shared/services/ai-bot.service';
import { AiChatModalService } from '../../shared/services/ai-chat-modal.service';
@Component({
selector: 'layout-basic',
@@ -75,9 +74,9 @@ import { AiBotService, ChatMessage } from '../../shared/services/ai-bot.service'
</global-footer>
<setting-drawer *ngIf="showSettingDrawer"></setting-drawer>
<!-- AI Chatbot -->
<div class="ai-chatbot-container">
<div class="ai-chatbot-button" (click)="toggleChatbot()" *ngIf="!isChatbotOpen">
<!-- AI Chat Button -->
<div class="ai-chat-button-container">
<div class="ai-chat-button" (click)="openChatModal()">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@@ -91,69 +90,11 @@ import { AiBotService, ChatMessage } from '../../shared/services/ai-bot.service'
/>
</svg>
</div>
<div class="ai-chatbot-window" *ngIf="isChatbotOpen" [class.maximized]="isChatbotMaximized">
<div class="chatbot-header">
<div class="chatbot-title">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
fill="white"
style="margin-right: 6px; vertical-align: middle;"
>
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2M7.5 13A2.5 2.5 0 0 0 5 15.5A2.5 2.5 0 0 0 7.5 18a2.5 2.5 0 0 0 2.5-2.5A2.5 2.5 0 0 0 7.5 13m9 0a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5z"
/>
</svg>
{{ 'ai.bot.title' | i18n }}
</div>
<div class="chatbot-controls">
<span class="control-item" (click)="toggleMaximize()" title="{{ isChatbotMaximized ? '还原' : '最大化' }}">
<i nz-icon [nzType]="isChatbotMaximized ? 'fullscreen-exit' : 'fullscreen'" nzTheme="outline"></i>
</span>
<span class="control-item" (click)="toggleChatbot()" title="关闭">
<i nz-icon nzType="close" nzTheme="outline"></i>
</span>
</div>
</div>
<div class="chatbot-messages" #chatMessagesContainer>
<div
*ngFor="let message of chatMessages"
[class.user-message]="message.isUser"
[class.bot-message]="!message.isUser"
class="message"
>
<div class="message-content">{{ message.content }}</div>
<div class="message-time">{{ message.timestamp | date : 'HH:mm' }}</div>
</div>
<div *ngIf="currentBotMessage && isLoading" class="bot-message streaming-message">
<div class="message-content">{{ currentBotMessage.content }}</div>
<div class="message-time">{{ currentBotMessage.timestamp | date : 'HH:mm' }}</div>
</div>
<div *ngIf="isLoading && !currentBotMessage" class="bot-message loading-message">
<nz-spin nzSimple></nz-spin>
</div>
</div>
<div class="chatbot-input">
<input
nz-input
placeholder="{{ 'ai.bot.input.placeholder' | i18n }}"
[(ngModel)]="currentMessage"
(keyup.enter)="sendMessage()"
[disabled]="isLoading"
/>
<button nz-button nzType="primary" [disabled]="!currentMessage.trim() || isLoading" (click)="sendMessage()">
{{ 'ai.bot.send' | i18n }}
</button>
</div>
</div>
</div>
`,
styleUrls: ['./basic.component.less']
})
export class LayoutBasicComponent implements OnInit, OnDestroy {
export class LayoutBasicComponent implements OnDestroy {
options: LayoutDefaultOptions = {
logoExpanded: `./assets/brand_white.svg`,
logoCollapsed: `./assets/logo.svg`
@@ -163,6 +104,7 @@ export class LayoutBasicComponent implements OnInit, OnDestroy {
showSettingDrawer = !environment.production;
version = CONSTANTS.VERSION;
currentYear = new Date().getFullYear();
get user(): User {
return this.settings.user;
}
@@ -177,127 +119,20 @@ export class LayoutBasicComponent implements OnInit, OnDestroy {
}
}
// AI Chatbot related properties
isChatbotOpen = false;
isChatbotMaximized = false;
chatMessages: ChatMessage[] = [];
currentMessage = '';
isLoading = false;
currentBotMessage: ChatMessage | null = null;
// For subscription cleanup
private destroy$ = new Subject<void>();
constructor(
private settings: SettingsService,
@Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService,
private aiBotService: AiBotService
private aiChatModalService: AiChatModalService
) {}
ngOnInit(): void {
// Initialize welcome message
this.chatMessages.push({
content: this.i18nSvc.fanyi('ai.bot.greeting'),
isUser: false,
timestamp: new Date()
});
console.log('AI Chatbot initialization completed');
}
ngOnDestroy(): void {
// Cancel all subscriptions when component is destroyed
this.destroy$.next();
this.destroy$.complete();
}
toggleChatbot(): void {
this.isChatbotOpen = !this.isChatbotOpen;
if (!this.isChatbotOpen) {
setTimeout(() => {
this.isChatbotMaximized = false;
}, 300);
} else {
// Scroll to bottom when window opens
setTimeout(() => this.scrollToBottom(), 100);
}
console.log('Toggle chatbot status:', this.isChatbotOpen ? 'open' : 'closed');
}
toggleMaximize(): void {
setTimeout(() => {
this.isChatbotMaximized = !this.isChatbotMaximized;
console.log('Chat window maximize status:', this.isChatbotMaximized ? 'maximized' : 'normal');
}, 10);
}
sendMessage(): void {
if (!this.currentMessage.trim() || this.isLoading) return;
// Add user message
this.chatMessages.push({
content: this.currentMessage,
isUser: true,
timestamp: new Date()
});
const userMessage = this.currentMessage;
this.currentMessage = '';
this.isLoading = true;
this.currentBotMessage = null;
// Ensure scrolling to bottom after message display
setTimeout(() => this.scrollToBottom(), 100);
// Call AI service to get response
this.aiBotService
.sendMessage(userMessage)
.pipe(
takeUntil(this.destroy$),
finalize(() => {
this.isLoading = false;
// If there is a current message, add it to chat history
if (this.currentBotMessage) {
this.chatMessages.push({ ...this.currentBotMessage });
this.currentBotMessage = null;
}
// Ensure scrolling to bottom after message display
setTimeout(() => this.scrollToBottom(), 100);
})
)
.subscribe({
next: response => {
console.log('Received AI response update:', response);
// Update currently receiving message
this.currentBotMessage = response;
// Scroll to bottom in real-time
this.scrollToBottom();
},
error: error => {
console.error('AI response error:', error);
// Add error message
this.chatMessages.push({
content: this.i18nSvc.fanyi('ai.bot.connect-fail'),
isUser: false,
timestamp: new Date()
});
}
});
}
// Scroll to bottom of messages
private scrollToBottom(): void {
try {
const chatMessages = document.querySelector('.chatbot-messages');
if (chatMessages) {
chatMessages.scrollTop = chatMessages.scrollHeight;
}
} catch (err) {
console.error('Failed to scroll to bottom:', err);
}
openChatModal(): void {
this.aiChatModalService.openChatModal();
}
}
+180
View File
@@ -0,0 +1,180 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { Message } from '../pojo/Message';
import { LocalStorageService } from './local-storage.service';
export interface ChatMessage {
content: string;
role: 'user' | 'assistant';
timestamp: Date;
}
export interface ConversationDto {
conversationId: string;
createdAt: Date;
updatedAt: Date;
messages: ChatMessage[];
}
export interface ChatRequestContext {
message: string;
conversationId?: string;
}
const chat_uri = '/chat';
@Injectable({
providedIn: 'root'
})
export class AiChatService {
constructor(private http: HttpClient, private localStorageService: LocalStorageService) {}
/**
* Create a new conversation
*/
createConversation(): Observable<Message<ConversationDto>> {
return this.http.post<Message<ConversationDto>>(`${chat_uri}/conversations`, {});
}
/**
* Get all conversations
*/
getConversations(): Observable<Message<ConversationDto[]>> {
return this.http.get<Message<ConversationDto[]>>(`${chat_uri}/conversations`);
}
/**
* Get a specific conversation with its history
*/
getConversation(conversationId: string): Observable<Message<ConversationDto>> {
return this.http.get<Message<ConversationDto>>(`${chat_uri}/conversations/${conversationId}`);
}
/**
* Delete a conversation
*/
deleteConversation(conversationId: string): Observable<Message<void>> {
return this.http.delete<Message<void>>(`${chat_uri}/conversations/${conversationId}`);
}
/**
* Send a message and get streaming response
*/
streamChat(message: string, conversationId?: string): Observable<ChatMessage> {
const responseSubject = new Subject<ChatMessage>();
const requestBody: ChatRequestContext = { message };
if (conversationId) {
requestBody.conversationId = conversationId;
}
const token = this.localStorageService.getAuthorizationToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
'Cache-Control': 'no-cache'
};
// Add Authorization header like the interceptor does
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Use fetch for SSE streaming (HttpClient doesn't support true streaming)
fetch(`/api${chat_uri}/stream`, {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No reader available');
}
const decoder = new TextDecoder();
let buffer = '';
function readStream(): Promise<void> {
if (!reader) {
return Promise.resolve();
}
return reader.read().then(({ value, done }) => {
if (done) {
responseSubject.complete();
return;
}
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('data:')) {
const jsonStr = trimmedLine.substring(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
try {
const data = JSON.parse(jsonStr);
if (data.response !== undefined) {
responseSubject.next({
content: data.response || '',
role: 'assistant',
timestamp: data.timestamp ? new Date(data.timestamp) : new Date()
});
}
} catch (parseError) {
console.error('Error parsing SSE data:', parseError, 'Raw data:', jsonStr);
if (jsonStr) {
responseSubject.next({
content: jsonStr,
role: 'assistant',
timestamp: new Date()
});
}
}
}
}
}
return readStream();
});
}
return readStream();
})
.catch(error => {
console.error('Chat stream error:', error);
responseSubject.error(error);
});
return responseSubject.asObservable();
}
}
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Message } from '../pojo/Message';
export interface OpenAiConfig {
enable: boolean;
apiKey: string;
}
export interface OpenAiConfigStatus {
configured: boolean;
hasDbConfig: boolean;
hasYamlConfig: boolean;
validationPassed: boolean;
validationMessage: string;
}
const openai_config_uri = '/ai-agent/config';
@Injectable({
providedIn: 'root'
})
export class OpenAiConfigService {
constructor(private http: HttpClient) {}
public saveOpenAiConfig(config: OpenAiConfig): Observable<Message<any>> {
return this.http.post<Message<any>>(`${openai_config_uri}/openai`, config);
}
public getOpenAiConfig(): Observable<Message<OpenAiConfig>> {
return this.http.get<Message<OpenAiConfig>>(`${openai_config_uri}/openai`);
}
public getOpenAiConfigStatus(): Observable<Message<OpenAiConfigStatus>> {
return this.http.get<Message<OpenAiConfigStatus>>(`${openai_config_uri}/openai/status`);
}
}
@@ -0,0 +1,55 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DelonFormModule } from '@delon/form';
import { AlainThemeModule } from '@delon/theme';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzMessageModule } from 'ng-zorro-antd/message';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { MarkdownPipe } from 'ngx-markdown';
import { ChatComponent } from './chat.component';
@NgModule({
declarations: [ChatComponent],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
AlainThemeModule.forChild(),
DelonFormModule,
NzButtonModule,
NzFormModule,
NzIconModule,
NzInputModule,
NzMessageModule,
NzModalModule,
NzSpinModule,
MarkdownPipe
],
exports: [ChatComponent]
})
export class AiChatModule {}
@@ -0,0 +1,182 @@
<!--
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
-->
<div class="chat-container">
<!-- Sidebar with conversation list -->
<div class="chat-sidebar" [class.collapsed]="sidebarCollapsed">
<div class="sidebar-header">
<h3>{{ 'menu.extras.ai.chat' | i18n }}</h3>
<button nz-button nzType="primary" nzSize="small" (click)="createNewConversation()" [nzLoading]="isLoading">
<i nz-icon nzType="plus"></i>
New Chat
</button>
</div>
<div class="conversation-list">
<div
*ngFor="let conversation of conversations"
class="conversation-item"
[class.active]="currentConversation?.conversationId === conversation.conversationId"
(click)="selectConversation(conversation)"
>
<div class="conversation-content">
<div class="conversation-title">
{{ getConversationTitle(conversation) }}
</div>
<div class="conversation-time">
{{ conversation.updatedAt | date : 'MMM d, HH:mm' }}
</div>
</div>
<button nz-button nzType="text" nzSize="small" nzDanger (click)="deleteConversation(conversation, $event)" class="delete-btn">
<i nz-icon nzType="delete"></i>
</button>
</div>
</div>
</div>
<!-- Main chat area -->
<div class="chat-main">
<!-- Header -->
<div class="chat-header">
<button nz-button nzType="text" (click)="toggleSidebar()" class="sidebar-toggle">
<i nz-icon [nzType]="sidebarCollapsed ? 'menu-unfold' : 'menu-fold'"></i>
</button>
<div class="chat-title">
<h2 *ngIf="currentConversation">
{{ getConversationTitle(currentConversation) }}
</h2>
<h2 *ngIf="!currentConversation"> HertzBeat AI Agent </h2>
</div>
</div>
<!-- Messages area -->
<div class="messages-container" #messagesContainer>
<!-- Loading indicator -->
<div *ngIf="isLoading" class="loading-container">
<nz-spin nzSimple [nzSize]="'large'">
<ng-template #indicator>
<i nz-icon nzType="loading" nzSpin></i>
</ng-template>
</nz-spin>
<p>Loading conversation history...</p>
</div>
<!-- Welcome message -->
<div *ngIf="messages.length === 0 && !isLoading" class="welcome-message">
<div class="welcome-content">
<img [src]="theme === 'dark' ? 'assets/logo_white.svg' : 'assets/logo.svg'" alt="HertzBeat Logo" class="welcome-icon" />
<h3>Welcome to HertzBeat AI Agent!</h3>
<p>
I'm your intelligent monitoring companion, ready to help you manage and optimize your infrastructure monitoring. You can ask me
to:
</p>
<ul>
<li>🔍 List and manage your existing monitors</li>
<li> Add new monitors for websites, APIs, databases, and services</li>
<li>📊 Get detailed information about available monitor types and their parameters</li>
<li>⚡ Check monitor status and troubleshoot monitoring issues</li>
</ul>
<p>Ask me anything about your monitoring setup!</p>
</div>
</div>
<!-- Chat messages -->
<div *ngFor="let message of messages; let i = index" class="message" [class.user-message]="message.role === 'user'">
<div class="message-avatar">
<i nz-icon [nzType]="message.role === 'user' ? 'user' : 'robot'"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-role">{{ message.role === 'user' ? 'You' : 'AI Assistant' }}</span>
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
<span *ngIf="message.role === 'assistant' && isLoading && i === messages.length - 1" class="streaming-indicator">
<nz-spin nzSize="small"></nz-spin>
</span>
</div>
<div class="message-text" [innerHTML]="message.content"></div>
<div *ngIf="message.role === 'assistant' && message.content === '' && isLoading" class="typing-indicator">
<nz-spin nzSize="small"></nz-spin>
<span>Starting to type...</span>
</div>
</div>
</div>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-container">
<nz-textarea-count [nzMaxCharacterCount]="1000">
<textarea
nz-input
[(ngModel)]="newMessage"
(keypress)="onKeyPress($event)"
[disabled]="isLoading"
placeholder="Ask me anything about monitoring..."
rows="3"
style="resize: none"
></textarea>
</nz-textarea-count>
<button
nz-button
nzType="primary"
(click)="sendMessage()"
[disabled]="!newMessage.trim() || isLoading"
[nzLoading]="isLoading"
class="send-button"
>
<i nz-icon nzType="send"></i>
</button>
</div>
<div class="input-hint">
<small>Press Enter to send, Shift+Enter for new line</small>
<button nz-button nzType="link" nzSize="small" (click)="onShowConfigModal()" style="float: right">
<i nz-icon nzType="setting"></i>
Modify API Key
</button>
</div>
</div>
</div>
</div>
<!-- OpenAI Configuration Modal -->
<nz-modal
[(nzVisible)]="showConfigModal"
nzTitle="OpenAI Configuration"
(nzOnCancel)="onCloseConfigModal()"
(nzOnOk)="onSaveOpenAiConfig()"
nzMaskClosable="false"
[nzClosable]="false"
nzWidth="600px"
[nzOkLoading]="configLoading"
nzOkText="Validate & Save"
nzCancelText="Cancel"
>
<div *nzModalContent class="-inner-content">
<form nz-form nzLayout="vertical">
<nz-form-item>
<nz-form-label nzRequired="true">OpenAI API Key</nz-form-label>
<nz-form-control nzErrorTip="API Key is required">
<input nz-input [(ngModel)]="openAiConfig.apiKey" name="apiKey" type="password" placeholder="sk-..." required />
<p class="form-help">Your OpenAI API key (starts with sk-). The key will be validated when saved.</p>
</nz-form-control>
</nz-form-item>
</form>
</div>
</nz-modal>
@@ -0,0 +1,649 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
@import '@delon/theme/index';
.chat-container {
display: flex;
height: 80vh; // Use viewport height for modal context
background: @layout-body-background;
.chat-sidebar {
width: 300px;
background: #fff !important;
border-right: 1px solid @border-color-base;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
&.collapsed {
width: 0;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid @border-color-base;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
color: @text-color;
}
}
.conversation-list {
flex: 1;
overflow-y: auto;
.conversation-item {
padding: 12px 16px;
border-bottom: 1px solid @border-color-split;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background-color 0.2s;
&:hover {
background-color: @item-hover-bg;
.delete-btn {
opacity: 1;
}
}
&.active {
background-color: @primary-1;
border-left: 3px solid @primary-color;
}
.conversation-content {
flex: 1;
min-width: 0;
.conversation-title {
font-size: 14px;
color: @text-color;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
font-size: 12px;
color: @text-color-secondary;
}
}
.delete-btn {
opacity: 0;
transition: opacity 0.2s;
margin-left: 8px;
}
}
}
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: @layout-body-background;
.chat-header {
padding: 16px;
border-bottom: 1px solid @border-color-base;
display: flex;
align-items: center;
gap: 12px;
.sidebar-toggle {
font-size: 16px;
}
.chat-title h2 {
margin: 0;
font-size: 18px;
color: @heading-color;
}
}
.messages-container {
flex: 1;
padding: 16px;
overflow-y: auto;
background: @layout-body-background;
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
p {
margin-top: 16px;
color: @text-color-secondary;
font-size: 14px;
}
}
.welcome-message {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
.welcome-content {
text-align: center;
max-width: 500px;
.welcome-icon {
width: 64px;
height: 62px;
margin-bottom: 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
h3 {
color: @heading-color;
margin-bottom: 16px;
}
p {
color: @text-color;
margin-bottom: 16px;
line-height: 1.5;
}
ul {
text-align: left;
color: @text-color;
margin: 16px 0;
li {
margin-bottom: 8px;
}
}
}
}
.message {
display: flex;
margin-bottom: 24px;
animation: fadeIn 0.3s ease;
&.user-message {
justify-content: flex-end;
.message-content {
background: #3f51b5;
color: #ffffff;
.message-text {
color: #ffffff;
}
.message-header {
.message-role {
color: rgba(255, 255, 255, 0.85);
}
.message-time {
color: rgba(255, 255, 255, 0.65);
}
}
}
.message-avatar {
order: 2;
margin-left: 12px;
margin-right: 0;
}
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 16px;
background: @background-color-light;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
i {
font-size: 16px;
color: @text-color-secondary;
}
}
.message-content {
max-width: 70%;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
box-shadow: @box-shadow-base;
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.message-role {
font-weight: 500;
font-size: 12px;
color: @text-color;
}
.message-time {
font-size: 11px;
color: @text-color-secondary;
}
.streaming-indicator {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
color: @primary-color;
font-size: 11px;
}
}
.message-text {
line-height: 1.5;
color: @text-color;
white-space: pre-wrap;
word-wrap: break-word;
// Markdown styling
:global {
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
code {
background: @background-color-light;
padding: 2px 4px;
border-radius: 3px;
font-family: @code-family;
font-size: 12px;
color: @text-color;
}
pre {
background: @background-color-light;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid @border-color-split;
code {
background: none;
padding: 0;
color: @text-color;
}
}
ul, ol {
margin: 8px 0;
padding-left: 20px;
}
blockquote {
border-left: 4px solid @border-color-base;
padding-left: 12px;
margin: 8px 0;
color: @text-color-secondary;
}
}
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: @text-color-secondary;
font-style: italic;
}
}
}
}
.input-area {
padding: 16px;
border-top: 1px solid @border-color-base;
background: #fff;
.input-container {
display: flex;
gap: 12px;
align-items: flex-end;
nz-textarea-count {
flex: 1;
}
.send-button {
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
}
.input-hint {
margin-top: 8px;
text-align: center;
small {
color: @text-color-secondary;
}
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Responsive design
@media (max-width: 768px) {
.chat-container {
.chat-sidebar {
position: absolute;
height: 100%;
z-index: 100;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
&.collapsed {
transform: translateX(-100%);
width: 300px;
}
}
.chat-main {
.messages-container {
.message {
&.user-message .message-content,
.message-content {
max-width: 85%;
}
}
}
}
}
}
// Dark theme overrides with maximum specificity
:host-context([data-theme='dark']) .chat-container,
[data-theme='dark'] .chat-container,
body[data-theme='dark'] .chat-container {
background: #000;
.chat-sidebar {
background: #000 !important;
border-right-color: @border-color-base;
.sidebar-header {
border-bottom-color: @border-color-base;
background: #000 !important;
}
.sidebar-header h3 {
color: fade(@white, 85%);
}
.conversation-list {
.conversation-item {
border-bottom-color: @border-color-split;
background: transparent;
border-left: 3px solid transparent;
&:hover {
background: transparent;
.conversation-content .conversation-title {
color: @primary-color;
}
}
&.active {
background: fade(@white, 5%);
border-left: 3px solid @primary-color;
.conversation-content .conversation-title {
color: @primary-color;
}
}
.conversation-content {
.conversation-title {
color: fade(@white, 65%);
}
.conversation-time {
color: fade(@white, 45%);
}
}
}
}
}
.chat-main {
background: #000;
.chat-header {
border-bottom-color: @border-color-base;
background: #000;
.chat-title h2 {
color: fade(@white, 85%);
}
}
.messages-container {
background: #000;
.loading-container {
p {
color: fade(@white, 65%);
}
}
.welcome-message .welcome-content {
h3 {
color: fade(@white, 85%);
}
p, ul {
color: fade(@white, 65%);
}
}
.message {
.message-avatar {
background: fade(@white, 8%);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
i {
color: fade(@white, 65%);
}
}
.message-content {
background: #141414 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.message-header {
.message-role {
color: fade(@white, 85%);
}
.message-time {
color: fade(@white, 45%);
}
.streaming-indicator {
color: @primary-color;
}
}
.message-text {
color: fade(@white, 85%);
}
.typing-indicator {
color: fade(@white, 45%);
}
}
&.user-message .message-content {
background: @primary-color;
.message-text {
color: @white;
}
.message-header {
.message-role {
color: fade(@white, 90%);
}
.message-time {
color: fade(@white, 70%);
}
}
}
}
}
.input-area {
background: #000 !important;
border-top-color: @border-color-base;
.input-hint small {
color: fade(@white, 45%);
}
}
}
}
// Force input styling for dark theme
[data-theme='dark'] {
.chat-container .input-area {
.ant-input {
background: fade(@white, 8%) !important;
border-color: @border-color-split !important;
color: fade(@white, 85%) !important;
&::placeholder {
color: fade(@white, 45%) !important;
}
&:focus, &:hover {
border-color: @primary-color !important;
box-shadow: 0 0 0 2px fade(@primary-color, 20%) !important;
}
}
.ant-input-data-count {
color: fade(@white, 45%) !important;
}
}
}
// Additional high-specificity overrides for dark theme
[data-theme="dark"] .chat-container .chat-sidebar {
background: #000 !important;
}
[data-theme="dark"] .chat-container .chat-sidebar .sidebar-header {
background: #000 !important;
}
[data-theme="dark"] .chat-container .chat-main .messages-container .message .message-content {
background: #141414 !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important;
border: none !important;
}
[data-theme="dark"] .chat-container .chat-main .input-area {
background: #000 !important;
}
// OpenAI Configuration Modal Styles
.config-alert {
margin-top: 16px;
}
.form-help {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
margin-bottom: 0;
}
.validation-result {
margin-top: 8px;
nz-alert {
font-size: 12px;
}
}
nz-modal {
.ant-modal-body {
padding: 24px;
}
.ant-form-item {
margin-bottom: 16px;
}
.ant-form-item-label {
padding-bottom: 4px;
}
}
@@ -0,0 +1,572 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Component, OnInit, ViewChild, ElementRef, AfterViewChecked, ChangeDetectorRef } from '@angular/core';
import { I18NService } from '@core';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalService } from 'ng-zorro-antd/modal';
import { AiChatService, ChatMessage, ConversationDto } from '../../../service/ai-chat.service';
import { OpenAiConfigService, OpenAiConfig, OpenAiConfigStatus } from '../../../service/openai-config.service';
import { ThemeService } from '../../../service/theme.service';
@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.less']
})
export class ChatComponent implements OnInit, AfterViewChecked {
@ViewChild('messagesContainer') private messagesContainer!: ElementRef;
conversations: ConversationDto[] = [];
currentConversation: ConversationDto | null = null;
messages: ChatMessage[] = [];
newMessage = '';
isLoading = false;
sidebarCollapsed = false;
theme: string = 'default';
// OpenAI Configuration
isOpenAiConfigured = false;
showConfigModal = false;
configLoading = false;
openAiConfig: OpenAiConfig = {
enable: false,
apiKey: ''
};
constructor(
private aiChatService: AiChatService,
private message: NzMessageService,
private modal: NzModalService,
private i18n: I18NService,
private cdr: ChangeDetectorRef,
private themeSvc: ThemeService,
private openAiConfigService: OpenAiConfigService
) {}
ngOnInit(): void {
this.theme = this.themeSvc.getTheme() || 'default';
this.checkOpenAiConfiguration();
}
ngAfterViewChecked(): void {
this.scrollToBottom();
}
/**
* Load all conversations
*/
loadConversations(): void {
console.log('Loading conversations...');
this.aiChatService.getConversations().subscribe({
next: response => {
console.log('Conversations response:', response);
if (response.code === 0 && response.data) {
this.conversations = response.data;
// If no current conversation, create a new one
if (this.conversations.length === 0) {
this.createNewConversation();
} else {
// Select the most recent conversation
this.selectConversation(this.conversations[0]);
}
} else {
console.error('Error in conversations response:', response);
this.message.error(`Failed to load conversations: ${response.msg || 'Unknown error'}`);
}
},
error: error => {
console.error('Error loading conversations:', error);
this.message.error(`Failed to load conversations: ${error.status} ${error.statusText || error.message}`);
// Create a fallback new conversation if API is not available
console.log('Creating fallback conversation...');
this.createFallbackConversation();
}
});
}
/**
* Create a new conversation
*/
createNewConversation(): void {
console.log('Creating new conversation...');
this.aiChatService.createConversation().subscribe({
next: response => {
console.log('Create conversation response:', response);
if (response.code === 0 && response.data) {
const newConversation = response.data;
this.conversations.unshift(newConversation);
this.selectConversation(newConversation);
this.message.success('New conversation created');
} else {
console.error('Error in create conversation response:', response);
this.message.error(`Failed to create conversation: ${response.msg || 'Unknown error'}`);
}
},
error: error => {
console.error('Error creating conversation:', error);
this.message.error(`Failed to create new conversation: ${error.status} ${error.statusText || error.message}`);
// Create fallback conversation
this.createFallbackConversation();
}
});
}
/**
* Create a fallback conversation when API is not available
*/
createFallbackConversation(): void {
const fallbackConversation: ConversationDto = {
conversationId: `fallback-${Date.now()}`,
createdAt: new Date(),
updatedAt: new Date(),
messages: []
};
this.conversations = [fallbackConversation];
this.selectConversation(fallbackConversation);
this.message.warning('AI Chat service unavailable. Running in offline mode.');
}
/**
* Select a conversation and load its messages
*/
selectConversation(conversation: ConversationDto): void {
this.currentConversation = conversation;
this.loadConversationHistory(conversation.conversationId);
}
/**
* Load conversation history from the API
*/
loadConversationHistory(conversationId: string): void {
this.isLoading = true;
this.aiChatService.getConversation(conversationId).subscribe({
next: response => {
this.isLoading = false;
console.log('Conversation history response:', response);
if (response.code === 0 && response.data) {
this.messages = response.data.messages || [];
this.cdr.detectChanges();
this.scrollToBottom();
} else {
console.error('Error loading conversation history:', response);
// Fallback to the messages from the conversation list if API fails
this.messages = this.currentConversation?.messages || [];
this.cdr.detectChanges();
this.scrollToBottom();
}
},
error: error => {
this.isLoading = false;
console.error('Error loading conversation history:', error);
// Fallback to the messages from the conversation list if API fails
this.messages = this.currentConversation?.messages || [];
this.cdr.detectChanges();
this.scrollToBottom();
}
});
}
/**
* Delete a conversation
*/
deleteConversation(conversation: ConversationDto, event: Event): void {
event.stopPropagation();
this.modal.confirm({
nzTitle: 'Delete Conversation',
nzContent: 'Are you sure you want to delete this conversation?',
nzOkText: 'Delete',
nzOkType: 'primary',
nzOkDanger: true,
nzOnOk: () => {
this.aiChatService.deleteConversation(conversation.conversationId).subscribe({
next: response => {
if (response.code === 0) {
// Remove from conversations list
this.conversations = this.conversations.filter(c => c.conversationId !== conversation.conversationId);
// If this was the current conversation, select another or create new
if (this.currentConversation?.conversationId === conversation.conversationId) {
if (this.conversations.length > 0) {
this.selectConversation(this.conversations[0]);
} else {
this.createNewConversation();
}
}
this.message.success('Conversation deleted');
}
},
error: error => {
console.error('Error deleting conversation:', error);
this.message.error('Failed to delete conversation');
}
});
}
});
}
/**
* Send a message
*/
sendMessage(): void {
if (!this.newMessage.trim() || this.isLoading) {
return;
}
const userMessage: ChatMessage = {
content: this.newMessage.trim(),
role: 'user',
timestamp: new Date()
};
// Add user message to the messages list
this.messages.push(userMessage);
const messageContent = this.newMessage.trim();
this.newMessage = '';
this.isLoading = true;
this.cdr.detectChanges();
this.scrollToBottom();
// Check if this is a fallback conversation
if (this.currentConversation?.conversationId.startsWith('fallback-')) {
console.log('Fallback mode - showing offline message');
setTimeout(() => {
const offlineMessage: ChatMessage = {
content:
'I apologize, but the AI Chat service is currently unavailable. Please ensure the HertzBeat AI Agent module is running and try again later.',
role: 'assistant',
timestamp: new Date()
};
this.messages.push(offlineMessage);
this.isLoading = false;
this.cdr.detectChanges();
this.scrollToBottom();
}, 1000);
return;
}
// Create empty assistant message for streaming
const assistantMessage: ChatMessage = {
content: '',
role: 'assistant',
timestamp: new Date()
};
this.messages.push(assistantMessage);
this.cdr.detectChanges();
this.scrollToBottom();
// Send to AI service
console.log('Sending message to AI service:', messageContent);
this.aiChatService.streamChat(messageContent, this.currentConversation?.conversationId).subscribe({
next: chunk => {
console.log('Received stream chunk:', chunk);
// Find the last assistant message and append content
const lastMessage = this.messages[this.messages.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
// Accumulate the content for streaming effect
lastMessage.content += chunk.content;
lastMessage.timestamp = chunk.timestamp instanceof Date ? chunk.timestamp : new Date();
this.cdr.detectChanges();
this.scrollToBottom();
}
},
error: error => {
console.error('Error in chat stream:', error);
this.message.error(`Failed to get AI response: ${error.status} ${error.statusText || error.message}`);
// Remove the empty assistant message and add error message
if (
this.messages.length > 0 &&
this.messages[this.messages.length - 1].role === 'assistant' &&
this.messages[this.messages.length - 1].content === ''
) {
this.messages.pop();
}
const errorMessage: ChatMessage = {
content: 'Sorry, there was an error processing your request. Please check if the AI Agent service is running and try again.',
role: 'assistant',
timestamp: new Date()
};
this.messages.push(errorMessage);
this.isLoading = false;
this.cdr.detectChanges();
this.scrollToBottom();
},
complete: () => {
console.log('Chat stream completed');
this.isLoading = false;
this.cdr.detectChanges();
// Refresh current conversation to get updated data (only if not fallback)
if (this.currentConversation && !this.currentConversation.conversationId.startsWith('fallback-')) {
this.aiChatService.getConversation(this.currentConversation.conversationId).subscribe({
next: response => {
if (response.code === 0 && response.data) {
// Update conversation in the list
const index = this.conversations.findIndex(c => c.conversationId === response.data!.conversationId);
if (index >= 0) {
this.conversations[index] = response.data;
}
this.currentConversation = response.data;
}
},
error: error => {
console.log('Error refreshing conversation (non-critical):', error);
}
});
}
}
});
}
/**
* Handle Enter key press
*/
onKeyPress(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
}
}
/**
* Toggle sidebar
*/
toggleSidebar(): void {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
/**
* Format conversation title
*/
getConversationTitle(conversation: ConversationDto): string {
if (conversation.messages && conversation.messages.length > 0) {
const firstUserMessage = conversation.messages.find(m => m.role === 'user');
if (firstUserMessage) {
return firstUserMessage.content.length > 30 ? `${firstUserMessage.content.substring(0, 30)}...` : firstUserMessage.content;
}
}
return `Conversation ${conversation.conversationId.substring(0, 8)}`;
}
/**
* Format time
*/
formatTime(date: Date): string {
if (!(date instanceof Date)) {
date = new Date(date);
}
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Process message content to ensure it's properly handled
*/
processMessage(message: ChatMessage): void {
// Handle Promise content
if (typeof message.content === 'object' && message.content !== null && 'then' in message.content) {
(message.content as Promise<any>)
.then((content: any) => {
message.content = String(content || '');
this.cdr.detectChanges();
})
.catch((error: any) => {
console.error('Error processing message content:', error);
message.content = 'Error loading message content';
this.cdr.detectChanges();
});
} else {
// Ensure content is always a string
message.content = String(message.content || '');
}
}
/**
* Scroll to bottom of messages
*/
private scrollToBottom(): void {
try {
if (this.messagesContainer) {
const element = this.messagesContainer.nativeElement;
element.scrollTop = element.scrollHeight;
}
} catch (err) {
console.error('Error scrolling to bottom:', err);
}
}
/**
* Check OpenAI configuration status
*/
checkOpenAiConfiguration(): void {
this.openAiConfigService.getOpenAiConfigStatus().subscribe({
next: response => {
if (response.code === 0) {
this.isOpenAiConfigured = response.data.configured;
if (this.isOpenAiConfigured) {
this.loadConversations();
} else {
this.showOpenAiConfigDialog(response.data);
}
} else {
console.error('Failed to check OpenAI configuration:', response.msg);
this.showOpenAiConfigDialog();
}
},
error: error => {
console.error('Error checking OpenAI configuration:', error);
this.showOpenAiConfigDialog();
}
});
}
/**
* Show OpenAI configuration dialog
*/
showOpenAiConfigDialog(status?: OpenAiConfigStatus): void {
// Load existing configuration if available
this.loadOpenAiConfig();
let contentMessage = `
<div style="margin-bottom: 16px;">
<p>To use AI Agent Chat, please configure your OpenAI API key.</p>
`;
if (status && !status.validationPassed && status.validationMessage) {
contentMessage += `
<div style="margin-bottom: 12px; padding: 8px; background: #fff2f0; border: 1px solid #ffccc7; border-radius: 4px;">
<strong>Configuration Issue:</strong> ${status.validationMessage}
</div>
`;
}
contentMessage += `
<p>You can either:</p>
<ul>
<li>Configure it here (stored in database)</li>
<li>Add it to your application.yml file under <code>spring.ai.openai.api-key</code></li>
</ul>
</div>
`;
const modalRef = this.modal.create({
nzTitle: 'OpenAI Configuration Required',
nzContent: contentMessage,
nzWidth: 600,
nzClosable: false,
nzMaskClosable: false,
nzFooter: [
{
label: 'Configure Here',
type: 'primary',
onClick: () => {
this.showConfigModal = true;
modalRef.destroy();
}
}
]
});
}
/**
* Load OpenAI configuration
*/
loadOpenAiConfig(): void {
this.openAiConfigService.getOpenAiConfig().subscribe({
next: response => {
if (response.code === 0 && response.data) {
this.openAiConfig = { ...this.openAiConfig, ...response.data };
}
},
error: error => {
console.error('Failed to load OpenAI config:', error);
}
});
}
/**
* Show configuration modal
*/
onShowConfigModal(): void {
this.loadOpenAiConfig();
this.showConfigModal = true;
}
/**
* Close configuration modal
*/
onCloseConfigModal(): void {
this.showConfigModal = false;
}
/**
* Save OpenAI configuration
*/
onSaveOpenAiConfig(): void {
if (!this.openAiConfig.apiKey.trim()) {
this.message.error('API Key is required');
return;
}
// Always enable when saving an API key
this.openAiConfig.enable = true;
this.configLoading = true;
this.message.info('Validating API key...', { nzDuration: 2000 });
this.openAiConfigService.saveOpenAiConfig(this.openAiConfig).subscribe({
next: response => {
this.configLoading = false;
if (response.code === 0) {
this.message.success('OpenAI API key validated and saved successfully!');
this.showConfigModal = false;
this.isOpenAiConfigured = true;
this.loadConversations();
} else {
// Check if it's a validation error
if (response.msg.includes('validation failed')) {
this.message.error(`❌ API Key validation failed: ${response.msg}`, { nzDuration: 5000 });
} else {
this.message.error(`Failed to save configuration: ${response.msg}`);
}
}
},
error: error => {
this.configLoading = false;
this.message.error(`Failed to save configuration: ${error.message}`);
}
});
}
}
@@ -0,0 +1,69 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Injectable, ComponentRef } from '@angular/core';
import { NzModalService } from 'ng-zorro-antd/modal';
import { ChatComponent } from '../components/ai-chat/chat.component';
@Injectable({
providedIn: 'root'
})
export class AiChatModalService {
private currentModal: any = null;
constructor(private modalService: NzModalService) {}
openChatModal(): void {
if (this.currentModal) {
this.currentModal.destroy();
}
this.currentModal = this.modalService.create({
nzTitle: '',
nzContent: ChatComponent,
nzFooter: null,
nzWidth: '90vw',
nzWrapClassName: 'ai-chat-modal',
nzCentered: true,
nzStyle: {
borderRadius: '16px',
overflow: 'hidden'
},
nzBodyStyle: {
padding: '0',
height: '80vh',
borderRadius: '16px'
},
nzMaskClosable: false,
nzClosable: true,
nzOnCancel: () => {
this.currentModal = null;
return true;
}
});
}
closeChatModal(): void {
if (this.currentModal) {
this.currentModal.destroy();
this.currentModal = null;
}
}
}
+5 -2
View File
@@ -21,6 +21,7 @@ import { NzTagModule } from 'ng-zorro-antd/tag';
const icons: IconDefinition[] = [RobotOutline, CloseOutline, SendOutline];
import { AiBotComponent } from './components/ai-bot/ai-bot.component';
import { AiChatModule } from './components/ai-chat/ai-chat.module';
import { ConfigurableFieldComponent } from './components/configurable-field/configurable-field.component';
import { FormFieldComponent } from './components/form-field/form-field.component';
import { HelpMessageShowComponent } from './components/help-message-show/help-message-show.component';
@@ -68,7 +69,8 @@ const DIRECTIVES: Array<Type<void>> = [TimezonePipe, I18nElsePipe, ElapsedTimePi
NzButtonModule,
NzInputModule,
NzIconModule.forChild(icons),
NzSpinModule
NzSpinModule,
AiChatModule
],
declarations: [...COMPONENTS, ...DIRECTIVES, HelpMessageShowComponent],
exports: [
@@ -83,7 +85,8 @@ const DIRECTIVES: Array<Type<void>> = [TimezonePipe, I18nElsePipe, ElapsedTimePi
...SHARED_ZORRO_MODULES,
...ThirdModules,
...COMPONENTS,
...DIRECTIVES
...DIRECTIVES,
AiChatModule
]
})
export class SharedModule {}
+1
View File
@@ -589,6 +589,7 @@
"menu.dashboard": "Dashboard",
"menu.extras": "More",
"menu.extras.about": "About",
"menu.extras.ai.chat": "AI Agent Chat",
"menu.extras.help": "Help",
"menu.extras.setting": "Setting",
"menu.extras.settings": "Settings",
+1
View File
@@ -589,6 +589,7 @@
"menu.dashboard": "ダッシュボード",
"menu.extras": "その他",
"menu.extras.about": "概要",
"menu.extras.ai.chat": "AIアシスタント",
"menu.extras.help": "ヘルプ",
"menu.extras.setting": "設定",
"menu.extras.settings": "設定",
+1
View File
@@ -630,6 +630,7 @@
"menu.dashboard": "Painel",
"menu.extras": "Mais",
"menu.extras.about": "sobre",
"menu.extras.ai.chat": "Assistente AI",
"menu.extras.help": "Centro de ajuda",
"menu.extras.setting": "configurar",
"menu.extras.settings": "Configurações do sistema",
+1
View File
@@ -589,6 +589,7 @@
"menu.dashboard": "仪表盘",
"menu.extras": "更多",
"menu.extras.about": "关于",
"menu.extras.ai.chat": "AI智能助手",
"menu.extras.help": "帮助中心",
"menu.extras.setting": "设置",
"menu.extras.settings": "系统设置",
+1
View File
@@ -588,6 +588,7 @@
"menu.dashboard": "儀表盤",
"menu.extras": "更多",
"menu.extras.about": "關於",
"menu.extras.ai.chat": "AI智能助手",
"menu.extras.help": "幫助中心",
"menu.extras.setting": "設置",
"menu.extras.settings": "系統設置",