You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@hertzbeat.apache.org by ga...@apache.org on 2026/02/02 15:12:02 UTC
(hertzbeat) 02/02: feat: implement AI scheduled monitoring with full i18n support - Add AI scheduled task feature for periodic SOP execution and results push. - Implement schedule management UI with create, edit, delete, and toggle support. - Support dynamic report language based on user's system locale configuration. - Add database persistence and Flyway migrations for SOP schedule tasks. - Refactor i18n utility to support background threads and update translations.
This is an automated email from the ASF dual-hosted git repository.
gaoxingcun pushed a commit to branch feature/ai-sop-workflow
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit 4540fe2fe9971cf3d3b202d1f75fe4ecd5476bbb
Author: TJxiaobao <29...@qq.com>
AuthorDate: Mon Feb 2 23:11:39 2026 +0800
feat: implement AI scheduled monitoring with full i18n support
- Add AI scheduled task feature for periodic SOP execution and results push.
- Implement schedule management UI with create, edit, delete, and toggle support.
- Support dynamic report language based on user's system locale configuration.
- Add database persistence and Flyway migrations for SOP schedule tasks.
- Refactor i18n utility to support background threads and update translations.
---
.../ai/controller/SopScheduleController.java | 131 ++++++++++
.../apache/hertzbeat/ai/dao/SopScheduleDao.java | 61 +++++
.../hertzbeat/ai/schedule/SopScheduleExecutor.java | 175 ++++++++++++++
.../hertzbeat/ai/service/SopScheduleService.java | 81 +++++++
.../impl/ChatClientProviderServiceImpl.java | 14 +-
.../ai/service/impl/McpServerServiceImpl.java | 5 +-
.../ai/service/impl/SopScheduleServiceImpl.java | 157 ++++++++++++
.../hertzbeat/ai/sop/executor/LlmExecutor.java | 2 +-
.../hertzbeat/ai/sop/model/OutputConfig.java | 10 +-
.../apache/hertzbeat/ai/sop/model/SopResult.java | 2 +-
.../apache/hertzbeat/ai/tools/ScheduleTools.java | 55 +++++
.../hertzbeat/ai/tools/impl/ScheduleToolsImpl.java | 268 +++++++++++++++++++++
.../ai/{sop/util => utils}/SopMessageUtil.java | 7 +-
.../src/main/resources/i18n/messages.properties | 37 +++
.../src/main/resources/i18n/messages_en.properties | 37 +++
.../src/main/resources/i18n/messages_zh.properties | 37 +++
.../src/main/resources/prompt/system-message.st | 39 ++-
.../src/main/resources/skills/daily_inspection.yml | 2 +-
.../hertzbeat/common/entity/ai/SopSchedule.java | 111 +++++++++
.../db/migration/h2/V181__update_column.sql | 18 ++
.../db/migration/mysql/V181__update_column.sql | 17 ++
.../migration/postgresql/V181__update_column.sql | 27 +++
web-app/src/app/service/ai-chat.service.ts | 65 ++++-
.../shared/components/ai-chat/ai-chat.module.ts | 8 +-
.../shared/components/ai-chat/chat.component.html | 221 ++++++++++++-----
.../shared/components/ai-chat/chat.component.less | 232 +++++++++++-------
.../shared/components/ai-chat/chat.component.ts | 204 +++++++++++++++-
web-app/src/assets/i18n/en-US.json | 38 ++-
web-app/src/assets/i18n/zh-CN.json | 38 ++-
29 files changed, 1925 insertions(+), 174 deletions(-)
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/controller/SopScheduleController.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/controller/SopScheduleController.java
new file mode 100644
index 0000000000..167f43866c
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/controller/SopScheduleController.java
@@ -0,0 +1,131 @@
+/*
+ * 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.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.apache.hertzbeat.common.entity.dto.Message;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * REST Controller for managing scheduled SOP executions.
+ */
+@Tag(name = "SOP Schedule API", description = "Manage scheduled SOP executions")
+@RestController
+@RequestMapping("/api/ai/schedule")
+public class SopScheduleController {
+
+ private final SopScheduleService sopScheduleService;
+ private final org.apache.hertzbeat.ai.sop.registry.SkillRegistry skillRegistry;
+
+ @Autowired
+ public SopScheduleController(SopScheduleService sopScheduleService,
+ org.apache.hertzbeat.ai.sop.registry.SkillRegistry skillRegistry) {
+ this.sopScheduleService = sopScheduleService;
+ this.skillRegistry = skillRegistry;
+ }
+
+ @Operation(summary = "Get all available SOP skills")
+ @GetMapping("/skills")
+ public ResponseEntity<Message<List<java.util.Map<String, String>>>> getAvailableSkills() {
+ List<java.util.Map<String, String>> skills = new java.util.ArrayList<>();
+ for (var skill : skillRegistry.getAllSkills()) {
+ java.util.Map<String, String> skillInfo = new java.util.HashMap<>();
+ skillInfo.put("name", skill.getName());
+ skillInfo.put("description", skill.getDescription());
+ skills.add(skillInfo);
+ }
+ return ResponseEntity.ok(Message.success(skills));
+ }
+
+ @Operation(summary = "Get all schedules for a conversation")
+ @GetMapping("/conversation/{conversationId}")
+ public ResponseEntity<Message<List<SopSchedule>>> getSchedulesByConversation(
+ @PathVariable Long conversationId) {
+ List<SopSchedule> schedules = sopScheduleService.getSchedulesByConversation(conversationId);
+ return ResponseEntity.ok(Message.success(schedules));
+ }
+
+ @Operation(summary = "Get a schedule by ID")
+ @GetMapping("/{id}")
+ public ResponseEntity<Message<SopSchedule>> getSchedule(@PathVariable Long id) {
+ SopSchedule schedule = sopScheduleService.getSchedule(id);
+ if (schedule == null) {
+ return ResponseEntity.ok(Message.fail((byte) 404, "Schedule not found"));
+ }
+ return ResponseEntity.ok(Message.success(schedule));
+ }
+
+ @Operation(summary = "Create a new scheduled SOP task")
+ @PostMapping
+ public ResponseEntity<Message<SopSchedule>> createSchedule(@RequestBody SopSchedule schedule) {
+ try {
+ SopSchedule created = sopScheduleService.createSchedule(schedule);
+ return ResponseEntity.ok(Message.success(created));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.ok(Message.fail((byte) 400, e.getMessage()));
+ }
+ }
+
+ @Operation(summary = "Update an existing schedule")
+ @PutMapping("/{id}")
+ public ResponseEntity<Message<SopSchedule>> updateSchedule(
+ @PathVariable Long id,
+ @RequestBody SopSchedule schedule) {
+ try {
+ schedule.setId(id);
+ SopSchedule updated = sopScheduleService.updateSchedule(schedule);
+ return ResponseEntity.ok(Message.success(updated));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.ok(Message.fail((byte) 400, e.getMessage()));
+ }
+ }
+
+ @Operation(summary = "Delete a schedule")
+ @DeleteMapping("/{id}")
+ public ResponseEntity<Message<Void>> deleteSchedule(@PathVariable Long id) {
+ sopScheduleService.deleteSchedule(id);
+ return ResponseEntity.ok(Message.success());
+ }
+
+ @Operation(summary = "Toggle schedule enabled status")
+ @PutMapping("/{id}/toggle")
+ public ResponseEntity<Message<SopSchedule>> toggleSchedule(
+ @PathVariable Long id,
+ @RequestParam boolean enabled) {
+ try {
+ SopSchedule updated = sopScheduleService.toggleSchedule(id, enabled);
+ return ResponseEntity.ok(Message.success(updated));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.ok(Message.fail((byte) 400, e.getMessage()));
+ }
+ }
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
new file mode 100644
index 0000000000..08861591e6
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/dao/SopScheduleDao.java
@@ -0,0 +1,61 @@
+/*
+ * 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.dao;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Repository for SopSchedule entity operations.
+ */
+@Repository
+public interface SopScheduleDao extends JpaRepository<SopSchedule, Long>, JpaSpecificationExecutor<SopSchedule> {
+
+ /**
+ * Find all schedules for a specific conversation.
+ * @param conversationId The conversation ID
+ * @return List of schedules
+ */
+ List<SopSchedule> findByConversationId(Long conversationId);
+
+ /**
+ * Find all enabled schedules that are due for execution.
+ * @param currentTime The current time to compare against
+ * @return List of due schedules
+ */
+ @Query("SELECT s FROM SopSchedule s WHERE s.enabled = true AND s.nextRunTime <= :currentTime")
+ List<SopSchedule> findDueSchedules(@Param("currentTime") LocalDateTime currentTime);
+
+ /**
+ * Find all enabled schedules.
+ * @return List of enabled schedules
+ */
+ List<SopSchedule> findByEnabledTrue();
+
+ /**
+ * Delete all schedules for a conversation.
+ * @param conversationId The conversation ID
+ */
+ void deleteByConversationId(Long conversationId);
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
new file mode 100644
index 0000000000..6e346f98c0
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/schedule/SopScheduleExecutor.java
@@ -0,0 +1,175 @@
+/*
+ * 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.schedule;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.ai.dao.ChatMessageDao;
+import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.ai.sop.engine.SopEngine;
+import org.apache.hertzbeat.ai.sop.model.SopResult;
+import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
+import org.apache.hertzbeat.ai.utils.SopMessageUtil;
+import org.apache.hertzbeat.common.entity.ai.ChatMessage;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * Scheduled executor that checks for due SOP schedules and executes them.
+ * Runs every minute to check if any schedules are due for execution.
+ */
+@Slf4j
+@Component
+@EnableScheduling
+public class SopScheduleExecutor {
+
+ /**
+ * Role identifier for system push messages in conversations.
+ */
+ public static final String ROLE_SYSTEM_PUSH = "system_push";
+
+ private final SopScheduleService sopScheduleService;
+ private final SopEngine sopEngine;
+ private final SkillRegistry skillRegistry;
+ private final ChatMessageDao chatMessageDao;
+
+ @Autowired
+ public SopScheduleExecutor(SopScheduleService sopScheduleService,
+ @Lazy SopEngine sopEngine,
+ @Lazy SkillRegistry skillRegistry,
+ ChatMessageDao chatMessageDao) {
+ this.sopScheduleService = sopScheduleService;
+ this.sopEngine = sopEngine;
+ this.skillRegistry = skillRegistry;
+ this.chatMessageDao = chatMessageDao;
+ }
+
+ /**
+ * Check for due schedules every minute and execute them.
+ */
+ @Scheduled(fixedRate = 60000) // Every minute
+ public void checkAndExecuteDueSchedules() {
+ try {
+ List<SopSchedule> dueSchedules = sopScheduleService.getDueSchedules();
+
+ if (dueSchedules.isEmpty()) {
+ return;
+ }
+
+ log.info("Found {} due schedules to execute", dueSchedules.size());
+
+ for (SopSchedule schedule : dueSchedules) {
+ executeSchedule(schedule);
+ }
+ } catch (Exception e) {
+ log.error("Error checking due schedules", e);
+ }
+ }
+
+ /**
+ * Execute a single scheduled SOP and push result to conversation.
+ */
+ private void executeSchedule(SopSchedule schedule) {
+ log.info("Executing scheduled SOP {} for conversation {}",
+ schedule.getSopName(), schedule.getConversationId());
+
+ try {
+ // Check if skill exists
+ var definition = skillRegistry.getSkill(schedule.getSopName());
+ if (definition == null) {
+ log.warn("Skill {} not found, skipping schedule {}",
+ schedule.getSopName(), schedule.getId());
+ return;
+ }
+
+ // Parse parameters
+ Map<String, Object> params = new HashMap<>();
+ if (schedule.getSopParams() != null && !schedule.getSopParams().isEmpty()) {
+ try {
+ params = JsonUtil.fromJson(schedule.getSopParams(), Map.class);
+ } catch (Exception e) {
+ log.warn("Failed to parse SOP params: {}", schedule.getSopParams());
+ }
+ }
+
+ // Execute SOP
+ SopResult result = sopEngine.executeSync(definition, params);
+
+ // Create push message
+ String messageContent = formatPushMessage(schedule, result);
+
+ // Save message to conversation
+ ChatMessage pushMessage = ChatMessage.builder()
+ .conversationId(schedule.getConversationId())
+ .role(ROLE_SYSTEM_PUSH)
+ .content(messageContent)
+ .build();
+
+ chatMessageDao.save(pushMessage);
+
+ log.info("Successfully pushed SOP result to conversation {}",
+ schedule.getConversationId());
+
+ } catch (Exception e) {
+ log.error("Failed to execute scheduled SOP {} for conversation {}",
+ schedule.getSopName(), schedule.getConversationId(), e);
+
+ // Still save an error message
+ String errorContent = SopMessageUtil.getMessage("schedule.push.error.prefix") + " " + schedule.getSopName()
+ + "\n\n" + SopMessageUtil.getMessage("schedule.push.error.label") + " " + e.getMessage();
+ ChatMessage errorMessage = ChatMessage.builder()
+ .conversationId(schedule.getConversationId())
+ .role(ROLE_SYSTEM_PUSH)
+ .content(errorContent)
+ .build();
+ chatMessageDao.save(errorMessage);
+
+ } finally {
+ // Update schedule times
+ sopScheduleService.updateAfterExecution(schedule.getId());
+ }
+ }
+
+ /**
+ * Format the push message content with SOP result.
+ */
+ private String formatPushMessage(SopSchedule schedule, SopResult result) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(SopMessageUtil.getMessage("schedule.push.report.title")).append("\n\n");
+ sb.append(SopMessageUtil.getMessage("schedule.push.report.skill")).append(": ").append(schedule.getSopName()).append("\n");
+ sb.append(SopMessageUtil.getMessage("schedule.push.report.time")).append(": ").append(LocalDateTime.now()).append("\n\n");
+ sb.append("---\n\n");
+
+ if ("SUCCESS".equals(result.getStatus())) {
+ sb.append(result.getContent());
+ } else {
+ sb.append(SopMessageUtil.getMessage("schedule.push.report.failed")).append("\n\n");
+ sb.append(result.getError());
+ }
+
+ return sb.toString();
+ }
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
new file mode 100644
index 0000000000..3f04938bf6
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/SopScheduleService.java
@@ -0,0 +1,81 @@
+/*
+ * 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.service;
+
+import java.util.List;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+
+/**
+ * Service interface for managing scheduled SOP executions.
+ */
+public interface SopScheduleService {
+
+ /**
+ * Create a new scheduled SOP task.
+ * @param schedule The schedule configuration
+ * @return The created schedule with generated ID
+ */
+ SopSchedule createSchedule(SopSchedule schedule);
+
+ /**
+ * Update an existing schedule.
+ * @param schedule The updated schedule
+ * @return The updated schedule
+ */
+ SopSchedule updateSchedule(SopSchedule schedule);
+
+ /**
+ * Delete a schedule by ID.
+ * @param id The schedule ID
+ */
+ void deleteSchedule(Long id);
+
+ /**
+ * Get a schedule by ID.
+ * @param id The schedule ID
+ * @return The schedule or null if not found
+ */
+ SopSchedule getSchedule(Long id);
+
+ /**
+ * Get all schedules for a conversation.
+ * @param conversationId The conversation ID
+ * @return List of schedules
+ */
+ List<SopSchedule> getSchedulesByConversation(Long conversationId);
+
+ /**
+ * Toggle the enabled status of a schedule.
+ * @param id The schedule ID
+ * @param enabled The new enabled status
+ * @return The updated schedule
+ */
+ SopSchedule toggleSchedule(Long id, boolean enabled);
+
+ /**
+ * Get all schedules that are due for execution.
+ * @return List of due schedules
+ */
+ List<SopSchedule> getDueSchedules();
+
+ /**
+ * Update the execution times after a schedule runs.
+ * @param id The schedule ID
+ */
+ void updateAfterExecution(Long id);
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImpl.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImpl.java
index 67211c1c02..edd8bd4ea8 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImpl.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ChatClientProviderServiceImpl.java
@@ -58,6 +58,7 @@ import java.util.List;
public class ChatClientProviderServiceImpl implements ChatClientProviderService {
private static final String SKILLS_PLACEHOLDER = "{dynamically_injected_skills_list}";
+ private static final String CONVERSATION_ID_PLACEHOLDER = "{current_conversation_id}";
private final ApplicationContext applicationContext;
@@ -106,8 +107,9 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
log.info("Starting streaming chat for conversation: {}", context.getConversationId());
- // Build system prompt with dynamic skills list
- String systemPrompt = buildSystemPrompt();
+ // Build system prompt with dynamic skills list and conversation ID
+ // The conversationId is injected into the prompt so AI can pass it to schedule tools
+ String systemPrompt = buildSystemPrompt(context.getConversationId());
return chatClient.prompt()
.messages(messages)
@@ -125,13 +127,15 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
}
/**
- * Build the system prompt with dynamically injected skills list.
+ * Build the system prompt with dynamically injected skills list and conversation ID.
*/
- private String buildSystemPrompt() {
+ private String buildSystemPrompt(Long conversationId) {
try {
String template = systemResource.getContentAsString(StandardCharsets.UTF_8);
String skillsList = generateSkillsList();
- return template.replace(SKILLS_PLACEHOLDER, skillsList);
+ return template
+ .replace(SKILLS_PLACEHOLDER, skillsList)
+ .replace(CONVERSATION_ID_PLACEHOLDER, String.valueOf(conversationId));
} catch (IOException e) {
log.error("Failed to read system prompt template: {}", e.getMessage());
return "";
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/McpServerServiceImpl.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/McpServerServiceImpl.java
index 02fc30c004..23c0dda3c1 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/McpServerServiceImpl.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/McpServerServiceImpl.java
@@ -23,6 +23,7 @@ import org.apache.hertzbeat.ai.tools.AlertDefineTools;
import org.apache.hertzbeat.ai.tools.AlertTools;
import org.apache.hertzbeat.ai.tools.MetricsTools;
import org.apache.hertzbeat.ai.tools.MonitorTools;
+import org.apache.hertzbeat.ai.tools.ScheduleTools;
import org.apache.hertzbeat.ai.tools.SkillTools;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -48,6 +49,8 @@ public class McpServerServiceImpl implements McpServerService {
private AlertDefineTools alertDefineTools;
@Autowired
private SkillTools skillTools;
+ @Autowired
+ private ScheduleTools scheduleTools;
@Bean
public ToolCallbackProvider hertzbeatTools() {
@@ -55,7 +58,7 @@ public class McpServerServiceImpl implements McpServerService {
// AI should use Skills (via skillTools) to perform database diagnostics.
return MethodToolCallbackProvider.builder()
.toolObjects(monitorTools, alertTools, alertDefineTools, metricsTools,
- skillTools)
+ skillTools, scheduleTools)
.build();
}
}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
new file mode 100644
index 0000000000..88c8d0a23b
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/SopScheduleServiceImpl.java
@@ -0,0 +1,157 @@
+/*
+ * 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.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.ai.dao.SopScheduleDao;
+import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.support.CronExpression;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Implementation of SopScheduleService for managing scheduled SOP executions.
+ */
+@Slf4j
+@Service
+public class SopScheduleServiceImpl implements SopScheduleService {
+
+ private final SopScheduleDao sopScheduleDao;
+
+ @Autowired
+ public SopScheduleServiceImpl(SopScheduleDao sopScheduleDao) {
+ this.sopScheduleDao = sopScheduleDao;
+ }
+
+ @Override
+ @Transactional
+ public SopSchedule createSchedule(SopSchedule schedule) {
+ // Validate cron expression
+ validateCronExpression(schedule.getCronExpression());
+
+ // Calculate next run time
+ schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
+ schedule.setEnabled(schedule.getEnabled() != null ? schedule.getEnabled() : true);
+
+ SopSchedule saved = sopScheduleDao.save(schedule);
+ log.info("Created schedule {} for conversation {} with SOP {}",
+ saved.getId(), saved.getConversationId(), saved.getSopName());
+ return saved;
+ }
+
+ @Override
+ @Transactional
+ public SopSchedule updateSchedule(SopSchedule schedule) {
+ SopSchedule existing = sopScheduleDao.findById(schedule.getId())
+ .orElseThrow(() -> new IllegalArgumentException("Schedule not found: " + schedule.getId()));
+
+ // Update fields
+ existing.setSopName(schedule.getSopName());
+ existing.setSopParams(schedule.getSopParams());
+
+ // If cron expression changed, recalculate next run time
+ if (!existing.getCronExpression().equals(schedule.getCronExpression())) {
+ validateCronExpression(schedule.getCronExpression());
+ existing.setCronExpression(schedule.getCronExpression());
+ existing.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
+ }
+
+ if (schedule.getEnabled() != null) {
+ existing.setEnabled(schedule.getEnabled());
+ }
+
+ return sopScheduleDao.save(existing);
+ }
+
+ @Override
+ @Transactional
+ public void deleteSchedule(Long id) {
+ log.info("Deleting schedule {}", id);
+ sopScheduleDao.deleteById(id);
+ }
+
+ @Override
+ public SopSchedule getSchedule(Long id) {
+ return sopScheduleDao.findById(id).orElse(null);
+ }
+
+ @Override
+ public List<SopSchedule> getSchedulesByConversation(Long conversationId) {
+ return sopScheduleDao.findByConversationId(conversationId);
+ }
+
+ @Override
+ @Transactional
+ public SopSchedule toggleSchedule(Long id, boolean enabled) {
+ SopSchedule schedule = sopScheduleDao.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Schedule not found: " + id));
+
+ schedule.setEnabled(enabled);
+
+ // If enabling, recalculate next run time
+ if (enabled) {
+ schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
+ }
+
+ log.info("Schedule {} {} ", id, enabled ? "enabled" : "disabled");
+ return sopScheduleDao.save(schedule);
+ }
+
+ @Override
+ public List<SopSchedule> getDueSchedules() {
+ return sopScheduleDao.findDueSchedules(LocalDateTime.now());
+ }
+
+ @Override
+ @Transactional
+ public void updateAfterExecution(Long id) {
+ SopSchedule schedule = sopScheduleDao.findById(id).orElse(null);
+ if (schedule == null) {
+ return;
+ }
+
+ schedule.setLastRunTime(LocalDateTime.now());
+ schedule.setNextRunTime(calculateNextRunTime(schedule.getCronExpression()));
+ sopScheduleDao.save(schedule);
+
+ log.debug("Updated schedule {} - Last run: {}, Next run: {}",
+ id, schedule.getLastRunTime(), schedule.getNextRunTime());
+ }
+
+ private void validateCronExpression(String cronExpression) {
+ try {
+ CronExpression.parse(cronExpression);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid cron expression: " + cronExpression, e);
+ }
+ }
+
+ private LocalDateTime calculateNextRunTime(String cronExpression) {
+ try {
+ CronExpression cron = CronExpression.parse(cronExpression);
+ return cron.next(LocalDateTime.now());
+ } catch (Exception e) {
+ log.error("Failed to calculate next run time for cron: {}", cronExpression, e);
+ return null;
+ }
+ }
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/executor/LlmExecutor.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/executor/LlmExecutor.java
index 0e58c385c6..1efaa2e7bf 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/executor/LlmExecutor.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/executor/LlmExecutor.java
@@ -21,7 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.service.ChatClientProviderService;
import org.apache.hertzbeat.ai.sop.model.SopStep;
-import org.apache.hertzbeat.ai.sop.util.SopMessageUtil;
+import org.apache.hertzbeat.ai.utils.SopMessageUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/OutputConfig.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/OutputConfig.java
index e236d444e9..c99bd93842 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/OutputConfig.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/OutputConfig.java
@@ -59,11 +59,17 @@ public class OutputConfig {
private String language;
/**
- * Get language code, default to zh (Chinese)
+ * Get language code.
+ * If not specified in skill config, uses system default locale (from user's SystemConfig).
*/
public String getLanguageCode() {
if (language == null || language.isEmpty()) {
- return "zh";
+ // Use system default locale (set by user's SystemConfig)
+ java.util.Locale defaultLocale = java.util.Locale.getDefault();
+ if (defaultLocale.getLanguage().equals("en")) {
+ return "en";
+ }
+ return "zh"; // Default to Chinese for non-English locales
}
return language.toLowerCase();
}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/SopResult.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/SopResult.java
index 1facf238a5..13ad85b7a8 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/SopResult.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/model/SopResult.java
@@ -25,7 +25,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
-import org.apache.hertzbeat.ai.sop.util.SopMessageUtil;
+import org.apache.hertzbeat.ai.utils.SopMessageUtil;
/**
* Unified SOP execution result.
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/ScheduleTools.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/ScheduleTools.java
new file mode 100644
index 0000000000..eb4154d40e
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/ScheduleTools.java
@@ -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.
+ */
+
+package org.apache.hertzbeat.ai.tools;
+
+/**
+ * Interface for AI Schedule management tools.
+ * Allows AI to create, list, and manage scheduled tasks via natural language.
+ */
+public interface ScheduleTools {
+
+ /**
+ * Create a scheduled task to execute a skill periodically.
+ * @param skillName Name of the skill to schedule (e.g., daily_inspection)
+ * @param cronExpression Cron expression for scheduling (6-digit Spring format)
+ * @param description Human-readable description of the schedule
+ * @return Confirmation message with schedule details
+ */
+ String createSchedule(String skillName, String cronExpression, String description);
+
+ /**
+ * List all scheduled tasks for the current conversation.
+ * @return List of scheduled tasks with their status
+ */
+ String listSchedules();
+
+ /**
+ * Delete a scheduled task by ID.
+ * @param scheduleId ID of the schedule to delete
+ * @return Confirmation message
+ */
+ String deleteSchedule(Long scheduleId);
+
+ /**
+ * Enable or disable a scheduled task.
+ * @param scheduleId ID of the schedule
+ * @param enabled Whether to enable or disable
+ * @return Confirmation message
+ */
+ String toggleSchedule(Long scheduleId, boolean enabled);
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
new file mode 100644
index 0000000000..f82260c4ee
--- /dev/null
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
@@ -0,0 +1,268 @@
+/*
+ * 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.tools.impl;
+
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
+import org.apache.hertzbeat.ai.utils.SopMessageUtil;
+import org.apache.hertzbeat.ai.tools.ScheduleTools;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+/**
+ * Implementation of ScheduleTools for AI-driven schedule management.
+ * Allows AI to create and manage scheduled tasks through natural language.
+ *
+ * The conversationId is injected into the System Prompt so AI can pass it
+ * when calling these tools.
+ */
+@Slf4j
+@Service
+public class ScheduleToolsImpl implements ScheduleTools {
+
+ private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final SopScheduleService scheduleService;
+ private final SkillRegistry skillRegistry;
+
+ @Autowired
+ public ScheduleToolsImpl(@Lazy SopScheduleService scheduleService,
+ @Lazy SkillRegistry skillRegistry) {
+ this.scheduleService = scheduleService;
+ this.skillRegistry = skillRegistry;
+ }
+
+ @Override
+ @Tool(name = "createScheduleForConversation",
+ description = "Create a scheduled task to automatically execute a skill at specified times. "
+ + "IMPORTANT: You must pass the conversationId from the system context (shown as 'Current Conversation ID' in the prompt). "
+ + "The cron expression should be in 6-digit Spring format: 'seconds minutes hours dayOfMonth month dayOfWeek'. "
+ + "Common examples: '0 0 9 * * ?' (daily at 9:00), '0 30 8 * * MON-FRI' (weekdays at 8:30).")
+ public String createSchedule(
+ @ToolParam(description = "Name of the skill to schedule (e.g., 'daily_inspection', 'mysql_slow_query_diagnosis')",
+ required = true) String skillName,
+ @ToolParam(description = "Cron expression in 6-digit Spring format. Examples: '0 0 9 * * ?' for daily 9am",
+ required = true) String cronExpression,
+ @ToolParam(description = "Human-readable description of what this schedule does",
+ required = false) String description) {
+ // This method signature is kept for interface compatibility
+ // AI should use createScheduleWithConversation instead
+ return SopMessageUtil.getMessage("schedule.error.use.with.conversation");
+ }
+
+ /**
+ * Create schedule with explicit conversationId - the primary method AI should call.
+ */
+ @Tool(name = "createScheduleWithConversation",
+ description = "Create a scheduled task for a specific conversation. "
+ + "Use the conversationId from the system context. "
+ + "The cron expression should be in 6-digit Spring format.")
+ public String createScheduleWithConversation(
+ @ToolParam(description = "Conversation ID from the system context", required = true) Long conversationId,
+ @ToolParam(description = "Name of the skill to schedule (e.g., 'daily_inspection')", required = true) String skillName,
+ @ToolParam(description = "Cron expression in Spring format (e.g., '0 0 9 * * ?')", required = true) String cronExpression,
+ @ToolParam(description = "Description of the schedule", required = false) String description) {
+
+ log.info("AI creating schedule: conversationId={}, skill={}, cron={}, desc={}",
+ conversationId, skillName, cronExpression, description);
+
+ // Validate skill exists
+ if (skillRegistry.getSkill(skillName) == null) {
+ String available = String.join(", ",
+ skillRegistry.getAllSkills().stream()
+ .map(s -> s.getName())
+ .toList());
+ return SopMessageUtil.getMessage("schedule.error.skill.not.found",
+ new Object[]{skillName, available}, null);
+ }
+
+ if (conversationId == null || conversationId <= 0) {
+ return SopMessageUtil.getMessage("schedule.error.invalid.conversation");
+ }
+
+ try {
+ // Check for duplicate schedule (same skill + cron expression)
+ List<SopSchedule> existing = scheduleService.getSchedulesByConversation(conversationId);
+ boolean duplicate = existing.stream()
+ .anyMatch(s -> s.getSopName().equals(skillName)
+ && s.getCronExpression().equals(cronExpression));
+ if (duplicate) {
+ return SopMessageUtil.getMessage("schedule.create.duplicate",
+ new Object[]{skillName, cronExpression}, null)
+ + "\n\n" + SopMessageUtil.getMessage("schedule.create.duplicate.hint");
+ }
+
+ SopSchedule schedule = new SopSchedule();
+ schedule.setConversationId(conversationId);
+ schedule.setSopName(skillName);
+ schedule.setCronExpression(cronExpression);
+ schedule.setEnabled(true);
+
+ SopSchedule created = scheduleService.createSchedule(schedule);
+
+ StringBuilder response = new StringBuilder();
+ response.append(SopMessageUtil.getMessage("schedule.create.success")).append("\n\n");
+ response.append("**").append(SopMessageUtil.getMessage("schedule.detail.title")).append("**:\n");
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.skill")).append("**: ").append(skillName).append("\n");
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.cron")).append("**: ").append(cronExpression).append("\n");
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.id")).append("**: ").append(created.getId()).append("\n");
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.status")).append("**: ").append(SopMessageUtil.getMessage("schedule.detail.enabled")).append("\n");
+
+ if (created.getNextRunTime() != null) {
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.next.run")).append("**: ").append(created.getNextRunTime().format(TIME_FORMATTER)).append("\n");
+ }
+
+ if (description != null && !description.isEmpty()) {
+ response.append("- **").append(SopMessageUtil.getMessage("schedule.detail.description")).append("**: ").append(description).append("\n");
+ }
+
+ response.append("\n").append(SopMessageUtil.getMessage("schedule.create.effective"));
+
+ return response.toString();
+
+ } catch (IllegalArgumentException e) {
+ return SopMessageUtil.getMessage("schedule.error.create.failed") + ": " + e.getMessage();
+ } catch (Exception e) {
+ log.error("Failed to create schedule", e);
+ return SopMessageUtil.getMessage("schedule.error.create.failed") + ": " + e.getMessage();
+ }
+ }
+
+ @Override
+ @Tool(name = "listSchedulesForConversation",
+ description = "List all scheduled tasks for a specific conversation. "
+ + "Use the conversationId from the system context.")
+ public String listSchedules() {
+ // This method signature is kept for interface compatibility
+ return SopMessageUtil.getMessage("schedule.error.use.with.conversation");
+ }
+
+ @Tool(name = "listSchedulesWithConversation",
+ description = "List all scheduled tasks for a specific conversation.")
+ public String listSchedulesWithConversation(
+ @ToolParam(description = "Conversation ID from the system context", required = true) Long conversationId) {
+
+ log.info("AI listing schedules for conversation: {}", conversationId);
+
+ if (conversationId == null || conversationId <= 0) {
+ return SopMessageUtil.getMessage("schedule.error.invalid.conversation");
+ }
+
+ try {
+ List<SopSchedule> schedules = scheduleService.getSchedulesByConversation(conversationId);
+
+ if (schedules.isEmpty()) {
+ return SopMessageUtil.getMessage("schedule.list.empty") + "\n\n"
+ + SopMessageUtil.getMessage("schedule.list.hint");
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("**").append(SopMessageUtil.getMessage("schedule.list.title")).append("** (")
+ .append(SopMessageUtil.getMessage("schedule.list.count", new Object[]{schedules.size()}, null))
+ .append("):\n\n");
+
+ for (SopSchedule schedule : schedules) {
+ String statusLabel = schedule.getEnabled()
+ ? SopMessageUtil.getMessage("schedule.detail.enabled")
+ : SopMessageUtil.getMessage("schedule.detail.disabled");
+
+ sb.append("### ").append(schedule.getId()).append(". ").append(schedule.getSopName());
+ sb.append(" (").append(statusLabel).append(")\n");
+ sb.append("- **").append(SopMessageUtil.getMessage("schedule.detail.cron")).append("**: `").append(schedule.getCronExpression()).append("`\n");
+ sb.append("- **").append(SopMessageUtil.getMessage("schedule.detail.status")).append("**: ").append(statusLabel).append("\n");
+
+ if (schedule.getLastRunTime() != null) {
+ sb.append("- **").append(SopMessageUtil.getMessage("schedule.detail.last.run")).append("**: ").append(schedule.getLastRunTime().format(TIME_FORMATTER)).append("\n");
+ }
+ if (schedule.getNextRunTime() != null) {
+ sb.append("- **").append(SopMessageUtil.getMessage("schedule.detail.next.run")).append("**: ").append(schedule.getNextRunTime().format(TIME_FORMATTER)).append("\n");
+ }
+ sb.append("\n");
+ }
+
+ return sb.toString();
+
+ } catch (Exception e) {
+ log.error("Failed to list schedules", e);
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ @Override
+ @Tool(name = "deleteSchedule",
+ description = "Delete a scheduled task by its ID. Use listSchedulesForConversation first to find the task ID.")
+ public String deleteSchedule(
+ @ToolParam(description = "ID of the schedule to delete", required = true) Long scheduleId) {
+
+ log.info("AI deleting schedule: {}", scheduleId);
+
+ try {
+ SopSchedule schedule = scheduleService.getSchedule(scheduleId);
+ if (schedule == null) {
+ return SopMessageUtil.getMessage("schedule.error.not.found", new Object[]{scheduleId}, null);
+ }
+
+ String skillName = schedule.getSopName();
+ scheduleService.deleteSchedule(scheduleId);
+
+ return SopMessageUtil.getMessage("schedule.delete.success", new Object[]{scheduleId, skillName}, null);
+
+ } catch (Exception e) {
+ log.error("Failed to delete schedule {}", scheduleId, e);
+ return "Error: " + e.getMessage();
+ }
+ }
+
+ @Override
+ @Tool(name = "toggleSchedule",
+ description = "Enable or disable a scheduled task. Use listSchedulesForConversation first to find the task ID.")
+ public String toggleSchedule(
+ @ToolParam(description = "ID of the schedule to toggle", required = true) Long scheduleId,
+ @ToolParam(description = "true to enable, false to disable", required = true) boolean enabled) {
+
+ log.info("AI toggling schedule {} to enabled={}", scheduleId, enabled);
+
+ try {
+ SopSchedule schedule = scheduleService.getSchedule(scheduleId);
+ if (schedule == null) {
+ return SopMessageUtil.getMessage("schedule.error.not.found", new Object[]{scheduleId}, null);
+ }
+
+ SopSchedule updated = scheduleService.toggleSchedule(scheduleId, enabled);
+
+ String messageKey = enabled ? "schedule.toggle.enabled" : "schedule.toggle.disabled";
+ String statusLabel = enabled
+ ? SopMessageUtil.getMessage("schedule.detail.enabled")
+ : SopMessageUtil.getMessage("schedule.detail.disabled");
+
+ return SopMessageUtil.getMessage(messageKey, new Object[]{scheduleId, updated.getSopName()}, null)
+ + "\n\n" + SopMessageUtil.getMessage("schedule.toggle.status") + ": " + statusLabel;
+
+ } catch (Exception e) {
+ log.error("Failed to toggle schedule {}", scheduleId, e);
+ return "Error: " + e.getMessage();
+ }
+ }
+}
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/util/SopMessageUtil.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/SopMessageUtil.java
similarity index 92%
rename from hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/util/SopMessageUtil.java
rename to hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/SopMessageUtil.java
index 9b38a48441..9d11457e87 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/sop/util/SopMessageUtil.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/utils/SopMessageUtil.java
@@ -15,12 +15,11 @@
* limitations under the License.
*/
-package org.apache.hertzbeat.ai.sop.util;
+package org.apache.hertzbeat.ai.utils;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.MessageSource;
-import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
/**
@@ -66,7 +65,9 @@ public class SopMessageUtil {
} else if ("zh".equalsIgnoreCase(language) || "chinese".equalsIgnoreCase(language)) {
locale = Locale.CHINESE;
} else {
- locale = LocaleContextHolder.getLocale();
+ // Use system default locale (set by user's SystemConfig)
+ // instead of LocaleContextHolder which only works in HTTP request context
+ locale = Locale.getDefault();
}
try {
diff --git a/hertzbeat-ai/src/main/resources/i18n/messages.properties b/hertzbeat-ai/src/main/resources/i18n/messages.properties
index 7a97ff01cf..fabab9ecc8 100644
--- a/hertzbeat-ai/src/main/resources/i18n/messages.properties
+++ b/hertzbeat-ai/src/main/resources/i18n/messages.properties
@@ -14,3 +14,40 @@ sop.result.operation.success=操作已完成
# LLM Language Instructions
sop.llm.language.instruction=重要:请使用中文回复。
+
+# Schedule Messages
+schedule.create.success=已成功创建定时任务!
+schedule.create.duplicate=该定时任务已存在(技能: {0}, 执行时间: {1}),无需重复创建。
+schedule.create.duplicate.hint=如需创建不同时间的任务,请指定不同的执行时间。
+schedule.detail.title=任务详情
+schedule.detail.skill=技能
+schedule.detail.cron=执行时间
+schedule.detail.id=任务ID
+schedule.detail.status=状态
+schedule.detail.enabled=已启用
+schedule.detail.disabled=已停用
+schedule.detail.next.run=下次执行
+schedule.detail.last.run=上次执行
+schedule.detail.description=描述
+schedule.create.effective=任务已创建并立即生效,系统将在设定时间自动执行。
+schedule.list.title=当前定时任务列表
+schedule.list.count=共 {0} 个
+schedule.list.empty=当前对话没有定时任务。
+schedule.list.hint=您可以说「每天早上9点执行每日巡检」来创建定时任务。
+schedule.delete.success=已删除定时任务 #{0} ({1})
+schedule.toggle.enabled=已启用定时任务 #{0} ({1})
+schedule.toggle.disabled=已停用定时任务 #{0} ({1})
+schedule.toggle.status=当前状态
+schedule.error.skill.not.found=技能 ''{0}'' 不存在。可用技能: {1}
+schedule.error.invalid.conversation=无效的会话ID,请从系统上下文传递正确的conversationId。
+schedule.error.not.found=未找到ID为 {0} 的定时任务。
+schedule.error.create.failed=创建定时任务失败
+schedule.error.use.with.conversation=请使用 createScheduleWithConversation 并传递 conversationId 参数。
+
+# Schedule Push Messages
+schedule.push.error.prefix=定时任务执行失败:
+schedule.push.error.label=错误:
+schedule.push.report.title=**定时任务执行报告**
+schedule.push.report.skill=**技能**
+schedule.push.report.time=**执行时间**
+schedule.push.report.failed=**执行失败**
diff --git a/hertzbeat-ai/src/main/resources/i18n/messages_en.properties b/hertzbeat-ai/src/main/resources/i18n/messages_en.properties
index f358ade8e1..7e943929b5 100644
--- a/hertzbeat-ai/src/main/resources/i18n/messages_en.properties
+++ b/hertzbeat-ai/src/main/resources/i18n/messages_en.properties
@@ -14,3 +14,40 @@ sop.result.operation.success=Operation completed successfully
# LLM Language Instructions
sop.llm.language.instruction=IMPORTANT: Please respond in English.
+
+# Schedule Messages
+schedule.create.success=Schedule created successfully!
+schedule.create.duplicate=This schedule already exists (skill: {0}, cron: {1}). No need to create a duplicate.
+schedule.create.duplicate.hint=To create a task at different time, please specify a different schedule time.
+schedule.detail.title=Task Details
+schedule.detail.skill=Skill
+schedule.detail.cron=Schedule Time
+schedule.detail.id=Task ID
+schedule.detail.status=Status
+schedule.detail.enabled=Enabled
+schedule.detail.disabled=Disabled
+schedule.detail.next.run=Next Run
+schedule.detail.last.run=Last Run
+schedule.detail.description=Description
+schedule.create.effective=Task created and effective immediately. The system will execute it at the scheduled time.
+schedule.list.title=Scheduled Tasks List
+schedule.list.count=Total: {0}
+schedule.list.empty=No scheduled tasks in current conversation.
+schedule.list.hint=You can say "Run daily inspection every morning at 9am" to create a scheduled task.
+schedule.delete.success=Deleted schedule #{0} ({1})
+schedule.toggle.enabled=Enabled schedule #{0} ({1})
+schedule.toggle.disabled=Disabled schedule #{0} ({1})
+schedule.toggle.status=Current Status
+schedule.error.skill.not.found=Skill ''{0}'' not found. Available skills: {1}
+schedule.error.invalid.conversation=Invalid conversation ID. Please pass the correct conversationId from system context.
+schedule.error.not.found=Schedule with ID {0} not found.
+schedule.error.create.failed=Failed to create schedule
+schedule.error.use.with.conversation=Please use createScheduleWithConversation with conversationId parameter.
+
+# Schedule Push Messages
+schedule.push.error.prefix=Scheduled task execution failed:
+schedule.push.error.label=Error:
+schedule.push.report.title=**Scheduled Task Execution Report**
+schedule.push.report.skill=**Skill**
+schedule.push.report.time=**Execution Time**
+schedule.push.report.failed=**Execution Failed**
diff --git a/hertzbeat-ai/src/main/resources/i18n/messages_zh.properties b/hertzbeat-ai/src/main/resources/i18n/messages_zh.properties
index 56c8232fc8..ea2789727e 100644
--- a/hertzbeat-ai/src/main/resources/i18n/messages_zh.properties
+++ b/hertzbeat-ai/src/main/resources/i18n/messages_zh.properties
@@ -14,3 +14,40 @@ sop.result.operation.success=操作已完成
# LLM Language Instructions
sop.llm.language.instruction=重要:请使用中文回复。
+
+# Schedule Messages
+schedule.create.success=已成功创建定时任务!
+schedule.create.duplicate=该定时任务已存在(技能: {0}, 执行时间: {1}),无需重复创建。
+schedule.create.duplicate.hint=如需创建不同时间的任务,请指定不同的执行时间。
+schedule.detail.title=任务详情
+schedule.detail.skill=技能
+schedule.detail.cron=执行时间
+schedule.detail.id=任务ID
+schedule.detail.status=状态
+schedule.detail.enabled=已启用
+schedule.detail.disabled=已停用
+schedule.detail.next.run=下次执行
+schedule.detail.last.run=上次执行
+schedule.detail.description=描述
+schedule.create.effective=任务已创建并立即生效,系统将在设定时间自动执行。
+schedule.list.title=当前定时任务列表
+schedule.list.count=共 {0} 个
+schedule.list.empty=当前对话没有定时任务。
+schedule.list.hint=您可以说「每天早上9点执行每日巡检」来创建定时任务。
+schedule.delete.success=已删除定时任务 #{0} ({1})
+schedule.toggle.enabled=已启用定时任务 #{0} ({1})
+schedule.toggle.disabled=已停用定时任务 #{0} ({1})
+schedule.toggle.status=当前状态
+schedule.error.skill.not.found=技能 ''{0}'' 不存在。可用技能: {1}
+schedule.error.invalid.conversation=无效的会话ID,请从系统上下文传递正确的conversationId。
+schedule.error.not.found=未找到ID为 {0} 的定时任务。
+schedule.error.create.failed=创建定时任务失败
+schedule.error.use.with.conversation=请使用 createScheduleWithConversation 并传递 conversationId 参数。
+
+# Schedule Push Messages
+schedule.push.error.prefix=定时任务执行失败:
+schedule.push.error.label=错误:
+schedule.push.report.title=**定时任务执行报告**
+schedule.push.report.skill=**技能**
+schedule.push.report.time=**执行时间**
+schedule.push.report.failed=**执行失败**
diff --git a/hertzbeat-ai/src/main/resources/prompt/system-message.st b/hertzbeat-ai/src/main/resources/prompt/system-message.st
index 3660af46ed..68463361e0 100644
--- a/hertzbeat-ai/src/main/resources/prompt/system-message.st
+++ b/hertzbeat-ai/src/main/resources/prompt/system-message.st
@@ -53,6 +53,38 @@ You have access to automated skills that can perform complex multi-step operatio
If no skill matches, use individual tools to assist the user.
+### Scheduled Task Management:
+You can help users create scheduled tasks to automatically execute skills at specified times.
+
+**Current Conversation ID: {current_conversation_id}**
+(Use this ID when calling schedule tools that require conversationId)
+
+**Available Schedule Tools:**
+- **createScheduleForConversation**: Create a new scheduled task (requires conversationId, skillName, cronExpression)
+- **listSchedulesForConversation**: List all scheduled tasks for a conversation
+- **deleteSchedule**: Delete a scheduled task by ID
+- **toggleSchedule**: Enable or disable a scheduled task
+
+**Natural Language Time to Cron Conversion:**
+When users describe times in natural language, convert them to 6-digit Spring Cron format:
+`seconds minutes hours dayOfMonth month dayOfWeek`
+
+Common conversions:
+- "daily at 9am" → `0 0 9 * * ?`
+- "daily at 6pm" → `0 0 18 * * ?`
+- "every Monday at 9am" → `0 0 9 ? * MON`
+- "weekdays at 8:30am" → `0 30 8 ? * MON-FRI`
+- "first day of each month" → `0 0 0 1 * ?`
+- "every hour" → `0 0 * * * ?`
+- "every 30 minutes" → `0 */30 * * * ?`
+
+**Workflow:**
+1. When user wants to schedule a skill, identify the skill name and time
+2. Convert natural language time to Cron expression
+3. **Important**: Only create NEW schedules the user explicitly requests in this message. Do NOT recreate schedules from conversation history.
+4. Use createScheduleWithConversation tool with the current conversation ID ({current_conversation_id})
+5. Confirm creation with task details and next run time
+
## Natural Language Examples:
@@ -80,7 +112,12 @@ If no skill matches, use individual tools to assist the user.
- "Find all alerts for monitor ID 1234 in the past day"
- "Which monitors are currently abnormal?"
-
+### Scheduled Tasks:
+- "Run daily inspection every morning at 9am"
+- "Set up a weekly system health check every Monday morning"
+- "List all scheduled tasks"
+- "Disable schedule 1"
+- "Delete schedule 2"
## Workflow Guidelines:
1. **Adding Monitors**:
diff --git a/hertzbeat-ai/src/main/resources/skills/daily_inspection.yml b/hertzbeat-ai/src/main/resources/skills/daily_inspection.yml
index eba0795104..2a5028ea6f 100644
--- a/hertzbeat-ai/src/main/resources/skills/daily_inspection.yml
+++ b/hertzbeat-ai/src/main/resources/skills/daily_inspection.yml
@@ -6,7 +6,7 @@ version: "1.0"
output:
type: report # report / simple / data / action
format: markdown # markdown / json / text
- language: zh # zh (Chinese) / en (English) - controls LLM response language
+ # language is not specified here - uses system default locale from user's SystemConfig
contentStep: generate_report # Which step's output is the main content
parameters:
diff --git a/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
new file mode 100644
index 0000000000..d0efbfb38f
--- /dev/null
+++ b/hertzbeat-common/src/main/java/org/apache/hertzbeat/common/entity/ai/SopSchedule.java
@@ -0,0 +1,111 @@
+/*
+ * 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.common.entity.ai;
+
+import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDateTime;
+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;
+
+/**
+ * Entity for storing scheduled SOP execution configurations.
+ * Allows users to schedule automatic SOP executions with results pushed to conversations.
+ */
+@Data
+@Builder
+@Entity
+@EntityListeners(AuditingEntityListener.class)
+@Table(name = "hzb_sop_schedule", indexes = {
+ @Index(name = "idx_schedule_conversation_id", columnList = "conversation_id"),
+ @Index(name = "idx_schedule_enabled_next", columnList = "enabled, next_run_time")
+})
+@AllArgsConstructor
+@NoArgsConstructor
+public class SopSchedule {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Schema(title = "Conversation ID to push results to")
+ @NotNull
+ @Column(name = "conversation_id")
+ private Long conversationId;
+
+ @Schema(title = "Name of the SOP skill to execute")
+ @NotBlank
+ @Column(name = "sop_name", length = 64)
+ private String sopName;
+
+ @Schema(title = "SOP execution parameters in JSON format")
+ @Column(name = "sop_params", length = 1024)
+ private String sopParams;
+
+ @Schema(title = "Cron expression for scheduling")
+ @NotBlank
+ @Column(name = "cron_expression", length = 64)
+ private String cronExpression;
+
+ @Schema(title = "Whether the schedule is enabled")
+ @Builder.Default
+ @Column(name = "enabled")
+ private Boolean enabled = true;
+
+ @Schema(title = "Last execution time")
+ @Column(name = "last_run_time")
+ private LocalDateTime lastRunTime;
+
+ @Schema(title = "Next scheduled execution time")
+ @Column(name = "next_run_time")
+ private LocalDateTime nextRunTime;
+
+ @Schema(title = "The creator of this record", accessMode = READ_ONLY)
+ @CreatedBy
+ private String creator;
+
+ @Schema(title = "The modifier of this record", accessMode = READ_ONLY)
+ @LastModifiedBy
+ private String modifier;
+
+ @Schema(title = "Record create time", accessMode = READ_ONLY)
+ @CreatedDate
+ private LocalDateTime gmtCreate;
+
+ @Schema(title = "Record modify time", accessMode = READ_ONLY)
+ @LastModifiedDate
+ private LocalDateTime gmtUpdate;
+}
diff --git a/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql
new file mode 100644
index 0000000000..6cc4c92b90
--- /dev/null
+++ b/hertzbeat-startup/src/main/resources/db/migration/h2/V181__update_column.sql
@@ -0,0 +1,18 @@
+-- Scheduled SOP execution configurations
+CREATE TABLE IF NOT EXISTS hzb_sop_schedule (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ conversation_id BIGINT NOT NULL COMMENT 'Conversation ID to push results to',
+ sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
+ sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
+ cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
+ enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
+ last_run_time DATETIME COMMENT 'Last execution time',
+ next_run_time DATETIME COMMENT 'Next scheduled execution time',
+ creator VARCHAR(64) COMMENT 'Creator of this record',
+ modifier VARCHAR(64) COMMENT 'Last modifier',
+ gmt_create DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
+ gmt_update DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update time'
+);
+
+CREATE INDEX IF NOT EXISTS idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
+CREATE INDEX IF NOT EXISTS idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
diff --git a/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql
new file mode 100644
index 0000000000..219efe0e95
--- /dev/null
+++ b/hertzbeat-startup/src/main/resources/db/migration/mysql/V181__update_column.sql
@@ -0,0 +1,17 @@
+-- Scheduled SOP execution configurations
+CREATE TABLE hzb_sop_schedule (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ conversation_id BIGINT NOT NULL COMMENT 'Conversation ID to push results to',
+ sop_name VARCHAR(64) NOT NULL COMMENT 'Name of the SOP skill to execute',
+ sop_params VARCHAR(1024) COMMENT 'SOP execution parameters in JSON format',
+ cron_expression VARCHAR(64) NOT NULL COMMENT 'Cron expression for scheduling',
+ enabled TINYINT DEFAULT 1 COMMENT 'Whether the schedule is enabled',
+ last_run_time DATETIME COMMENT 'Last execution time',
+ next_run_time DATETIME COMMENT 'Next scheduled execution time',
+ creator VARCHAR(64) COMMENT 'Creator of this record',
+ modifier VARCHAR(64) COMMENT 'Last modifier',
+ gmt_create DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
+ gmt_update DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update time',
+ INDEX idx_schedule_conversation_id (conversation_id),
+ INDEX idx_schedule_enabled_next (enabled, next_run_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
diff --git a/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql
new file mode 100644
index 0000000000..dac5d4db54
--- /dev/null
+++ b/hertzbeat-startup/src/main/resources/db/migration/postgresql/V181__update_column.sql
@@ -0,0 +1,27 @@
+-- Scheduled SOP execution configurations
+CREATE TABLE hzb_sop_schedule (
+ id BIGSERIAL PRIMARY KEY,
+ conversation_id BIGINT NOT NULL,
+ sop_name VARCHAR(64) NOT NULL,
+ sop_params VARCHAR(1024),
+ cron_expression VARCHAR(64) NOT NULL,
+ enabled SMALLINT DEFAULT 1,
+ last_run_time TIMESTAMP,
+ next_run_time TIMESTAMP,
+ creator VARCHAR(64),
+ modifier VARCHAR(64),
+ gmt_create TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ gmt_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMENT ON TABLE hzb_sop_schedule IS 'Scheduled SOP execution configurations';
+COMMENT ON COLUMN hzb_sop_schedule.conversation_id IS 'Conversation ID to push results to';
+COMMENT ON COLUMN hzb_sop_schedule.sop_name IS 'Name of the SOP skill to execute';
+COMMENT ON COLUMN hzb_sop_schedule.sop_params IS 'SOP execution parameters in JSON format';
+COMMENT ON COLUMN hzb_sop_schedule.cron_expression IS 'Cron expression for scheduling';
+COMMENT ON COLUMN hzb_sop_schedule.enabled IS 'Whether the schedule is enabled';
+COMMENT ON COLUMN hzb_sop_schedule.last_run_time IS 'Last execution time';
+COMMENT ON COLUMN hzb_sop_schedule.next_run_time IS 'Next scheduled execution time';
+
+CREATE INDEX idx_schedule_conversation_id ON hzb_sop_schedule(conversation_id);
+CREATE INDEX idx_schedule_enabled_next ON hzb_sop_schedule(enabled, next_run_time);
diff --git a/web-app/src/app/service/ai-chat.service.ts b/web-app/src/app/service/ai-chat.service.ts
index 96e54c0ae9..88bb95fc91 100644
--- a/web-app/src/app/service/ai-chat.service.ts
+++ b/web-app/src/app/service/ai-chat.service.ts
@@ -26,7 +26,7 @@ import { LocalStorageService } from './local-storage.service';
export interface ChatMessage {
content: string;
- role: 'user' | 'assistant';
+ role: 'user' | 'assistant' | 'system_push';
gmtCreate: Date;
/** Flag indicating this message is a skill report that should be displayed directly */
isSkillReport?: boolean;
@@ -45,7 +45,26 @@ export interface ChatRequestContext {
conversationId?: number;
}
+export interface SopSchedule {
+ id?: number;
+ conversationId: number;
+ sopName: string;
+ sopParams?: string;
+ cronExpression: string;
+ enabled: boolean;
+ lastRunTime?: Date;
+ nextRunTime?: Date;
+ gmtCreate?: Date;
+ gmtUpdate?: Date;
+}
+
+export interface SkillInfo {
+ name: string;
+ description: string;
+}
+
const chat_uri = '/chat';
+const schedule_uri = '/ai/schedule';
@Injectable({
providedIn: 'root'
@@ -180,4 +199,48 @@ export class AiChatService {
return responseSubject.asObservable();
}
+
+ // ===== Schedule API Methods =====
+
+ /**
+ * Get all schedules for a conversation
+ */
+ getSchedules(conversationId: number): Observable<Message<SopSchedule[]>> {
+ return this.http.get<Message<SopSchedule[]>>(`${schedule_uri}/conversation/${conversationId}`);
+ }
+
+ /**
+ * Create a new schedule
+ */
+ createSchedule(schedule: SopSchedule): Observable<Message<SopSchedule>> {
+ return this.http.post<Message<SopSchedule>>(schedule_uri, schedule);
+ }
+
+ /**
+ * Update a schedule
+ */
+ updateSchedule(id: number, schedule: SopSchedule): Observable<Message<SopSchedule>> {
+ return this.http.put<Message<SopSchedule>>(`${schedule_uri}/${id}`, schedule);
+ }
+
+ /**
+ * Delete a schedule
+ */
+ deleteSchedule(id: number): Observable<Message<void>> {
+ return this.http.delete<Message<void>>(`${schedule_uri}/${id}`);
+ }
+
+ /**
+ * Toggle schedule enabled status
+ */
+ toggleSchedule(id: number, enabled: boolean): Observable<Message<SopSchedule>> {
+ return this.http.put<Message<SopSchedule>>(`${schedule_uri}/${id}/toggle?enabled=${enabled}`, {});
+ }
+
+ /**
+ * Get available SOP skills
+ */
+ getAvailableSkills(): Observable<Message<SkillInfo[]>> {
+ return this.http.get<Message<SkillInfo[]>>(`${schedule_uri}/skills`);
+ }
}
diff --git a/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts b/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts
index a154954c7a..f2b061cc88 100644
--- a/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts
+++ b/web-app/src/app/shared/components/ai-chat/ai-chat.module.ts
@@ -23,6 +23,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DelonFormModule } from '@delon/form';
import { AlainThemeModule } from '@delon/theme';
import { NzButtonModule } from 'ng-zorro-antd/button';
+import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
@@ -30,6 +31,8 @@ import { NzMessageModule } from 'ng-zorro-antd/message';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzSpinModule } from 'ng-zorro-antd/spin';
+import { NzSwitchModule } from 'ng-zorro-antd/switch';
+import { NzTableModule } from 'ng-zorro-antd/table';
import { NzTooltipDirective } from 'ng-zorro-antd/tooltip';
import { MarkdownComponent } from 'ngx-markdown';
@@ -44,6 +47,7 @@ import { ChatComponent } from './chat.component';
AlainThemeModule.forChild(),
DelonFormModule,
NzButtonModule,
+ NzDividerModule,
NzFormModule,
NzIconModule,
NzInputModule,
@@ -51,9 +55,11 @@ import { ChatComponent } from './chat.component';
NzModalModule,
NzSelectModule,
NzSpinModule,
+ NzSwitchModule,
+ NzTableModule,
MarkdownComponent,
NzTooltipDirective
],
exports: [ChatComponent]
})
-export class AiChatModule {}
+export class AiChatModule { }
diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.html b/web-app/src/app/shared/components/ai-chat/chat.component.html
index a09c0a91c1..08f84c6d51 100644
--- a/web-app/src/app/shared/components/ai-chat/chat.component.html
+++ b/web-app/src/app/shared/components/ai-chat/chat.component.html
@@ -29,12 +29,8 @@
</div>
<div class="conversation-list">
- <div
- *ngFor="let conversation of conversations"
- class="conversation-item"
- [class.active]="currentConversation?.id === conversation.id"
- (click)="selectConversation(conversation)"
- >
+ <div *ngFor="let conversation of conversations" class="conversation-item"
+ [class.active]="currentConversation?.id === conversation.id" (click)="selectConversation(conversation)">
<div class="conversation-content">
<div class="conversation-title">
{{ getConversationTitle(conversation) }}
@@ -43,7 +39,8 @@
{{ conversation.gmtUpdate | date : 'MMM d, HH:mm' }}
</div>
</div>
- <button nz-button nzType="text" nzSize="small" nzDanger (click)="deleteConversation(conversation, $event)" class="delete-btn">
+ <button nz-button nzType="text" nzSize="small" nzDanger (click)="deleteConversation(conversation, $event)"
+ class="delete-btn">
<i nz-icon nzType="delete"></i>
</button>
</div>
@@ -64,6 +61,17 @@
</h2>
<h2 *ngIf="!currentConversation">{{ 'ai.chat.title' | i18n }}</h2>
</div>
+
+ <!-- Spacer to push actions to the left of page-level buttons -->
+ <div class="header-spacer"></div>
+
+ <div class="chat-header-actions" *ngIf="currentConversation">
+ <button nz-button nzType="primary" nzSize="small" nzGhost (click)="onShowScheduleModal()" nz-tooltip
+ [nzTooltipTitle]="'ai.chat.schedule.tooltip' | i18n">
+ <i nz-icon nzType="clock-circle" nzTheme="outline"></i>
+ <span>{{ 'ai.chat.schedule.button' | i18n }}</span>
+ </button>
+ </div>
</div>
<!-- Messages area -->
@@ -81,7 +89,8 @@
<!-- Welcome message -->
<div *ngIf="messages.length === 0 && !isLoadingConversations" class="welcome-message">
<div class="welcome-content">
- <img [src]="theme === 'dark' ? 'assets/logo_white.svg' : 'assets/logo.svg'" alt="HertzBeat Logo" class="welcome-icon" />
+ <img [src]="theme === 'dark' ? 'assets/logo_white.svg' : 'assets/logo.svg'" alt="HertzBeat Logo"
+ class="welcome-icon" />
<h3 class="welcome-title">{{ 'ai.chat.welcome.title' | i18n }}</h3>
<p class="welcome-description">
{{ 'ai.chat.welcome.description' | i18n }}
@@ -98,27 +107,36 @@
</div>
<!-- Chat messages -->
- <div *ngFor="let message of messages; let i = index" class="message" [class.user-message]="message.role === 'user'">
+ <div *ngFor="let message of messages; let i = index" class="message"
+ [class.user-message]="message.role === 'user'" [class.system-push-message]="message.role === 'system_push'">
<div class="message-avatar">
- <i nz-icon [nzType]="message.role === 'user' ? 'user' : 'robot'"></i>
+ <i nz-icon
+ [nzType]="message.role === 'user' ? 'user' : (message.role === 'system_push' ? 'bell' : 'robot')"></i>
</div>
<div class="message-content">
<div class="message-header">
- <span class="message-role">{{ message.role === 'user' ? ('ai.chat.user' | i18n) : ('ai.chat.assistant' | i18n) }}</span>
+ <span class="message-role">
+ <ng-container [ngSwitch]="message.role">
+ <span *ngSwitchCase="'user'">{{ 'ai.chat.user' | i18n }}</span>
+ <span *ngSwitchCase="'system_push'">🔔 定时推送</span>
+ <span *ngSwitchDefault>{{ 'ai.chat.assistant' | i18n }}</span>
+ </ng-container>
+ </span>
<span class="message-time">{{ formatTime(message.gmtCreate) }}</span>
<span
*ngIf="message.role === 'assistant' && isSendingMessage && i === messages.length - 1 && message.content !== ''"
- class="streaming-indicator"
- >
+ class="streaming-indicator">
<i nz-icon nzType="loading" nzSpin style="font-size: 12px"></i>
<span style="font-size: 11px">Typing...</span>
</span>
</div>
<div class="message-text">
<div *ngIf="message.role === 'user'" [innerHTML]="message.content"></div>
- <markdown *ngIf="message.role === 'assistant'" [data]="message.content"></markdown>
+ <markdown *ngIf="message.role === 'assistant' || message.role === 'system_push'" [data]="message.content">
+ </markdown>
</div>
- <div *ngIf="message.role === 'assistant' && message.content === '' && isSendingMessage" class="typing-indicator">
+ <div *ngIf="message.role === 'assistant' && message.content === '' && isSendingMessage"
+ class="typing-indicator">
<span>{{ 'ai.chat.typing' | i18n }}</span>
</div>
</div>
@@ -129,17 +147,11 @@
<div class="input-area">
<div class="input-container">
<nz-textarea-count [nzMaxCharacterCount]="1000">
- <textarea
- nz-input
- [(ngModel)]="newMessage"
- (keypress)="onKeyPress($event)"
- [disabled]="isSendingMessage"
- placeholder="{{ 'ai.chat.input.placeholder' | i18n }}"
- rows="3"
- style="resize: none"
- ></textarea>
+ <textarea nz-input [(ngModel)]="newMessage" (keypress)="onKeyPress($event)" [disabled]="isSendingMessage"
+ placeholder="{{ 'ai.chat.input.placeholder' | i18n }}" rows="3" style="resize: none"></textarea>
</nz-textarea-count>
- <button nz-button nzType="primary" (click)="sendMessage()" [disabled]="!newMessage.trim() || isSendingMessage" class="send-button">
+ <button nz-button nzType="primary" (click)="sendMessage()" [disabled]="!newMessage.trim() || isSendingMessage"
+ class="send-button">
<i nz-icon [nzType]="isSendingMessage ? 'loading' : 'send'" [nzSpin]="isSendingMessage"></i>
</button>
</div>
@@ -155,32 +167,21 @@
</div>
<!-- OpenAI Configuration Modal -->
-<nz-modal
- [(nzVisible)]="showConfigModal"
- [nzTitle]="'ai.chat.config.title' | i18n"
- (nzOnCancel)="onCloseConfigModal()"
- (nzOnOk)="onSaveAiProviderConfig()"
- nzMaskClosable="false"
- [nzClosable]="false"
- nzWidth="700px"
- [nzOkLoading]="configLoading"
- [nzOkText]="'ai.chat.config.save' | i18n"
- [nzCancelText]="'ai.chat.config.cancel' | i18n"
->
+<nz-modal [(nzVisible)]="showConfigModal" [nzTitle]="'ai.chat.config.title' | i18n" (nzOnCancel)="onCloseConfigModal()"
+ (nzOnOk)="onSaveAiProviderConfig()" nzMaskClosable="false" [nzClosable]="false" nzWidth="700px"
+ [nzOkLoading]="configLoading" [nzOkText]="'ai.chat.config.save' | i18n"
+ [nzCancelText]="'ai.chat.config.cancel' | i18n">
<div *nzModalContent class="-inner-content">
<form nz-form nzLayout="vertical">
<!-- Provider Selection -->
<nz-form-item>
<nz-form-label nzRequired="true">{{ 'ai.chat.config.provider' | i18n }}</nz-form-label>
<nz-form-control [nzErrorTip]="'ai.chat.config.provider.required' | i18n">
- <nz-select
- [(ngModel)]="aiProviderConfig.code"
- name="provider"
- [nzPlaceHolder]="'ai.chat.config.provider.help' | i18n"
- (ngModelChange)="onProviderChange($event)"
- style="width: 100%"
- >
- <nz-option *ngFor="let option of providerOptions" [nzValue]="option.value" [nzLabel]="option.label"></nz-option>
+ <nz-select [(ngModel)]="aiProviderConfig.code" name="provider"
+ [nzPlaceHolder]="'ai.chat.config.provider.help' | i18n" (ngModelChange)="onProviderChange($event)"
+ style="width: 100%">
+ <nz-option *ngFor="let option of providerOptions" [nzValue]="option.value"
+ [nzLabel]="option.label"></nz-option>
</nz-select>
<p class="form-help">Choose your AI provider (OpenAI, ZhiPu, or ZAI)</p>
</nz-form-control>
@@ -190,7 +191,8 @@
<nz-form-item>
<nz-form-label nzRequired="true">{{ 'ai.chat.config.api-key' | i18n }}</nz-form-label>
<nz-form-control [nzErrorTip]="'ai.chat.config.api-key.required' | i18n">
- <input nz-input [(ngModel)]="aiProviderConfig.apiKey" name="apiKey" type="password" placeholder="sk-..." required />
+ <input nz-input [(ngModel)]="aiProviderConfig.apiKey" name="apiKey" type="password" placeholder="sk-..."
+ required />
<p class="form-help">{{ 'ai.chat.config.api-key.help' | i18n }}</p>
</nz-form-control>
</nz-form-item>
@@ -200,16 +202,11 @@
<nz-form-label>{{ 'ai.chat.config.base-url' | i18n }}</nz-form-label>
<nz-form-control>
<nz-input-group [nzSuffix]="resetBtn">
- <input nz-input [(ngModel)]="aiProviderConfig.baseUrl" name="baseUrl" placeholder="https://api.openai.com/v1" />
+ <input nz-input [(ngModel)]="aiProviderConfig.baseUrl" name="baseUrl"
+ placeholder="https://api.openai.com/v1" />
<ng-template #resetBtn>
- <button
- nz-button
- nzType="link"
- nzSize="small"
- (click)="resetToDefaults()"
- [nzTooltipTitle]="'ai.chat.config.reset' | i18n"
- nz-tooltip
- >
+ <button nz-button nzType="link" nzSize="small" (click)="resetToDefaults()"
+ [nzTooltipTitle]="'ai.chat.config.reset' | i18n" nz-tooltip>
<i nz-icon nzType="reload"></i>
</button>
</ng-template>
@@ -229,3 +226,117 @@
</form>
</div>
</nz-modal>
+
+<!-- Schedule Configuration Modal -->
+<nz-modal [(nzVisible)]="showScheduleModal" [nzTitle]="'ai.chat.schedule.title' | i18n"
+ (nzOnCancel)="onCloseScheduleModal()" [nzFooter]="null" nzWidth="600px">
+ <div *nzModalContent class="schedule-modal-content">
+ <!-- Existing Schedules List -->
+ <div class="schedule-list" *ngIf="schedules.length > 0">
+ <h4>{{ 'ai.chat.schedule.configured' | i18n }}</h4>
+ <nz-table #scheduleTable [nzData]="schedules" [nzShowPagination]="false" nzSize="small">
+ <thead>
+ <tr>
+ <th>{{ 'ai.chat.schedule.skill' | i18n }}</th>
+ <th>{{ 'ai.chat.schedule.cron' | i18n }}</th>
+ <th>{{ 'ai.chat.schedule.status' | i18n }}</th>
+ <th>{{ 'ai.chat.schedule.action' | i18n }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr *ngFor="let schedule of scheduleTable.data">
+ <td>{{ schedule.sopName }}</td>
+ <td>{{ schedule.cronExpression }}</td>
+ <td>
+ <nz-switch [(ngModel)]="schedule.enabled" (ngModelChange)="onToggleSchedule(schedule)"></nz-switch>
+ </td>
+ <td>
+ <button nz-button nzType="text" nzSize="small" (click)="onEditSchedule(schedule)" nz-tooltip
+ [nzTooltipTitle]="'common.button.edit' | i18n">
+ <i nz-icon nzType="edit"></i>
+ </button>
+ <button nz-button nzType="text" nzDanger nzSize="small" (click)="onDeleteSchedule(schedule)" nz-tooltip
+ [nzTooltipTitle]="'common.button.delete' | i18n">
+ <i nz-icon nzType="delete"></i>
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </nz-table>
+ </div>
+
+ <nz-divider *ngIf="schedules.length > 0"></nz-divider>
+
+ <!-- Edit Schedule Form -->
+ <div *ngIf="editingSchedule">
+ <h4>{{ 'ai.chat.schedule.edit' | i18n }}</h4>
+ <form nz-form nzLayout="vertical">
+ <nz-form-item>
+ <nz-form-label>{{ 'ai.chat.schedule.skill' | i18n }}</nz-form-label>
+ <nz-form-control>
+ <input nz-input [value]="editingSchedule.sopName" disabled />
+ </nz-form-control>
+ </nz-form-item>
+
+ <nz-form-item>
+ <nz-form-label nzRequired>{{ 'ai.chat.schedule.cron.label' | i18n }}</nz-form-label>
+ <nz-form-control>
+ <input nz-input [(ngModel)]="editingSchedule.cronExpression" name="editCronExpression"
+ placeholder="0 0 9 * * ?" />
+ </nz-form-control>
+ </nz-form-item>
+
+ <nz-form-item>
+ <button nz-button nzType="primary" (click)="onUpdateSchedule()" [nzLoading]="scheduleLoading"
+ [disabled]="!editingSchedule.cronExpression" style="margin-right: 8px">
+ <i nz-icon nzType="save"></i> {{ 'common.button.save' | i18n }}
+ </button>
+ <button nz-button (click)="onCancelEdit()">
+ {{ 'common.button.cancel' | i18n }}
+ </button>
+ </nz-form-item>
+ </form>
+ <nz-divider></nz-divider>
+ </div>
+
+ <!-- Add New Schedule Form -->
+ <h4>{{ schedules.length > 0 ? ('ai.chat.schedule.add' | i18n) : ('ai.chat.schedule.create' | i18n) }}</h4>
+ <form nz-form nzLayout="vertical">
+ <nz-form-item>
+ <nz-form-label nzRequired>{{ 'ai.chat.schedule.skill.select' | i18n }}</nz-form-label>
+ <nz-form-control>
+ <nz-select [(ngModel)]="newSchedule.sopName" name="sopName"
+ [nzPlaceHolder]="'ai.chat.schedule.skill.placeholder' | i18n" style="width: 100%">
+ <nz-option *ngFor="let skill of availableSkills" [nzValue]="skill.name"
+ [nzLabel]="skill.name + ' - ' + skill.description">
+ </nz-option>
+ </nz-select>
+ </nz-form-control>
+ </nz-form-item>
+
+ <nz-form-item>
+ <nz-form-label nzRequired>{{ 'ai.chat.schedule.cron.label' | i18n }}</nz-form-label>
+ <nz-form-control>
+ <nz-input-group [nzSuffix]="cronHelpTpl">
+ <input nz-input [(ngModel)]="newSchedule.cronExpression" name="cronExpression" placeholder="0 0 9 * * ?" />
+ </nz-input-group>
+ <ng-template #cronHelpTpl>
+ <i nz-icon nzType="question-circle" nz-tooltip
+ [nzTooltipTitle]="('ai.chat.schedule.cron.help' | i18n) + ' ' + ('ai.chat.schedule.cron.example.daily' | i18n) + ' ' + ('ai.chat.schedule.cron.example.weekly' | i18n)"></i>
+ </ng-template>
+ <p class="form-help">
+ {{ 'ai.chat.schedule.cron.common' | i18n }} <code>0 0 9 * * ?</code> | {{ 'ai.chat.schedule.cron.monday' |
+ i18n }} <code>0 0 9 ? * MON</code>
+ </p>
+ </nz-form-control>
+ </nz-form-item>
+
+ <nz-form-item>
+ <button nz-button nzType="primary" (click)="onCreateSchedule()" [nzLoading]="scheduleLoading"
+ [disabled]="!newSchedule.sopName || !newSchedule.cronExpression">
+ <i nz-icon nzType="plus"></i> {{ 'ai.chat.schedule.create' | i18n }}
+ </button>
+ </nz-form-item>
+ </form>
+ </div>
+</nz-modal>
\ No newline at end of file
diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.less b/web-app/src/app/shared/components/ai-chat/chat.component.less
index 23a198c5d4..48427a11da 100644
--- a/web-app/src/app/shared/components/ai-chat/chat.component.less
+++ b/web-app/src/app/shared/components/ai-chat/chat.component.less
@@ -143,10 +143,32 @@
font-size: 16px;
}
- .chat-title h2 {
- margin: 0;
- font-size: 18px;
- color: @heading-color;
+ .chat-title {
+ flex: 1;
+
+ h2 {
+ margin: 0;
+ font-size: 18px;
+ color: @heading-color;
+ }
+ }
+
+ .header-spacer {
+ flex: 1;
+ }
+
+ .chat-header-actions {
+ margin-right: 80px; // Leave space for page-level buttons
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ i {
+ font-size: 14px;
+ }
+ }
}
}
@@ -267,6 +289,26 @@
}
}
+ &.system-push-message {
+ .message-avatar {
+ background: #e6f7ff;
+
+ i {
+ color: #1890ff;
+ }
+ }
+
+ .message-content {
+ background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
+ border-left: 3px solid #1890ff;
+
+ .message-header .message-role {
+ color: #096dd9;
+ font-weight: 600;
+ }
+ }
+ }
+
.message-avatar {
width: 32px;
height: 32px;
@@ -381,6 +423,7 @@
opacity: 0;
transform: translateY(10px);
}
+
to {
opacity: 1;
transform: translateY(0);
@@ -406,6 +449,7 @@
.chat-main {
.messages-container {
.message {
+
&.user-message .message-content,
.message-content {
max-width: 85%;
@@ -475,140 +519,141 @@ body[data-theme='dark'] .chat-container {
color: fade(@white, 85%);
}
- .conversation-list {
- .conversation-item {
- border-bottom-color: @border-color-split;
- background: transparent;
- border-left: 3px solid transparent;
+ .conversation-list {
+ .conversation-item {
+ border-bottom-color: @border-color-split;
+ background: transparent;
+ border-left: 3px solid transparent;
- &:hover {
- background: transparent;
+ &:hover {
+ background: transparent;
- .conversation-content .conversation-title {
- color: @primary-color;
- }
+ .conversation-content .conversation-title {
+ color: @primary-color;
}
+ }
- &.active {
- background: fade(@white, 5%);
- border-left: 3px solid @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: @primary-color;
}
+ }
- .conversation-content {
- .conversation-title {
- color: fade(@white, 65%);
- }
+ .conversation-content {
+ .conversation-title {
+ color: fade(@white, 65%);
+ }
- .conversation-time {
- color: fade(@white, 45%);
- }
+ .conversation-time {
+ color: fade(@white, 45%);
}
}
}
}
+ }
- .chat-main {
+ .chat-main {
+ background: #000;
+
+ .chat-header {
+ border-bottom-color: @border-color-base;
background: #000;
- .chat-header {
- border-bottom-color: @border-color-base;
- background: #000;
+ .chat-title h2 {
+ color: fade(@white, 85%);
+ }
+ }
+
+ .messages-container {
+ background: #000;
- .chat-title h2 {
- color: fade(@white, 85%);
+ .loading-container {
+ p {
+ color: fade(@white, 65%);
}
}
- .messages-container {
- background: #000;
+ .welcome-message .welcome-content {
+ h3 {
+ color: fade(@white, 85%);
+ }
- .loading-container {
- p {
- color: fade(@white, 65%);
- }
+ p,
+ ul {
+ color: fade(@white, 65%);
}
+ }
- .welcome-message .welcome-content {
- h3 {
- color: fade(@white, 85%);
- }
+ .message {
+ .message-avatar {
+ background: fade(@white, 8%);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
- p, ul {
+ i {
color: fade(@white, 65%);
}
}
- .message {
- .message-avatar {
- background: fade(@white, 8%);
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
+ .message-content {
+ background: #141414 !important;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
- i {
- color: fade(@white, 65%);
+ .message-header {
+ .message-role {
+ color: fade(@white, 85%);
}
- }
- .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-time {
+ color: fade(@white, 45%);
}
- .message-text {
- color: fade(@white, 85%);
+ .streaming-indicator {
+ color: @primary-color;
}
+ }
- .typing-indicator {
- color: fade(@white, 45%);
- }
+ .message-text {
+ color: fade(@white, 85%);
}
- &.user-message .message-content {
- background: @primary-color;
+ .typing-indicator {
+ color: fade(@white, 45%);
+ }
+ }
- .message-text {
- color: @white;
- }
+ &.user-message .message-content {
+ background: @primary-color;
- .message-header {
- .message-role {
- color: fade(@white, 90%);
- }
+ .message-text {
+ color: @white;
+ }
- .message-time {
- color: fade(@white, 70%);
- }
+ .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-area {
+ background: #000 !important;
+ border-top-color: @border-color-base;
- .input-hint small {
- color: fade(@white, 45%);
- }
+ .input-hint small {
+ color: fade(@white, 45%);
}
}
}
+}
@@ -624,7 +669,8 @@ body[data-theme='dark'] .chat-container {
color: fade(@white, 45%) !important;
}
- &:focus, &:hover {
+ &:focus,
+ &:hover {
border-color: @primary-color !important;
box-shadow: 0 0 0 2px fade(@primary-color, 20%) !important;
}
@@ -745,7 +791,7 @@ body[data-theme='dark'] .chat-container {
.form-item-with-help {
margin-bottom: 8px;
- + .form-help {
+ +.form-help {
margin-bottom: 16px;
}
}
@@ -763,4 +809,4 @@ nz-modal {
.ant-form-item-label {
padding-bottom: 4px;
}
-}
+}
\ No newline at end of file
diff --git a/web-app/src/app/shared/components/ai-chat/chat.component.ts b/web-app/src/app/shared/components/ai-chat/chat.component.ts
index 482cb13399..26fa2da2d4 100644
--- a/web-app/src/app/shared/components/ai-chat/chat.component.ts
+++ b/web-app/src/app/shared/components/ai-chat/chat.component.ts
@@ -24,7 +24,7 @@ import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalService } from 'ng-zorro-antd/modal';
import { ModelProviderConfig, PROVIDER_OPTIONS, ProviderOption } from '../../../pojo/ModelProviderConfig';
-import { AiChatService, ChatMessage, ChatConversation } from '../../../service/ai-chat.service';
+import { AiChatService, ChatMessage, ChatConversation, SopSchedule, SkillInfo } from '../../../service/ai-chat.service';
import { GeneralConfigService } from '../../../service/general-config.service';
import { ThemeService } from '../../../service/theme.service';
@@ -61,6 +61,18 @@ export class ChatComponent implements OnInit, OnDestroy {
aiProviderConfig: ModelProviderConfig = new ModelProviderConfig();
providerOptions: ProviderOption[] = PROVIDER_OPTIONS;
+ // Schedule Configuration
+ showScheduleModal = false;
+ scheduleLoading = false;
+ schedules: SopSchedule[] = [];
+ availableSkills: SkillInfo[] = [];
+ newSchedule: Partial<SopSchedule> = {
+ sopName: '',
+ cronExpression: '',
+ enabled: true
+ };
+ editingSchedule: SopSchedule | null = null;
+
constructor(
private aiChatService: AiChatService,
private message: NzMessageService,
@@ -649,4 +661,194 @@ export class ChatComponent implements OnInit, OnDestroy {
this.aiProviderConfig.model = selectedProvider.defaultModel;
}
}
+
+ // ===== Schedule Management Methods =====
+
+ /**
+ * Show schedule configuration modal
+ */
+ onShowScheduleModal(): void {
+ if (!this.currentConversation) {
+ this.message.warning('请先选择或创建一个对话');
+ return;
+ }
+ this.showScheduleModal = true;
+ this.loadSchedules();
+ this.loadAvailableSkills();
+ }
+
+ /**
+ * Close schedule modal
+ */
+ onCloseScheduleModal(): void {
+ this.showScheduleModal = false;
+ }
+
+ /**
+ * Load available SOP skills from backend
+ */
+ loadAvailableSkills(): void {
+ this.aiChatService.getAvailableSkills().subscribe({
+ next: response => {
+ if (response.code === 0 && response.data) {
+ this.availableSkills = response.data;
+ }
+ },
+ error: error => {
+ console.error('Error loading skills:', error);
+ // Fallback to empty - user will see nothing in dropdown
+ }
+ });
+ }
+
+ /**
+ * Load schedules for current conversation
+ */
+ loadSchedules(): void {
+ if (!this.currentConversation) return;
+
+ this.aiChatService.getSchedules(this.currentConversation.id).subscribe({
+ next: response => {
+ if (response.code === 0 && response.data) {
+ this.schedules = response.data;
+ }
+ },
+ error: error => {
+ console.error('Error loading schedules:', error);
+ this.message.error('加载定时任务失败');
+ }
+ });
+ }
+
+ /**
+ * Create a new schedule
+ */
+ onCreateSchedule(): void {
+ if (!this.currentConversation || !this.newSchedule.sopName || !this.newSchedule.cronExpression) {
+ return;
+ }
+
+ this.scheduleLoading = true;
+ const schedule: SopSchedule = {
+ conversationId: this.currentConversation.id,
+ sopName: this.newSchedule.sopName,
+ cronExpression: this.newSchedule.cronExpression,
+ enabled: true
+ };
+
+ this.aiChatService.createSchedule(schedule).subscribe({
+ next: response => {
+ this.scheduleLoading = false;
+ if (response.code === 0 && response.data) {
+ this.schedules.push(response.data);
+ this.newSchedule = { sopName: '', cronExpression: '', enabled: true };
+ this.message.success(this.i18nSvc.fanyi('ai.chat.schedule.create.success'));
+ } else {
+ this.message.error(`${this.i18nSvc.fanyi('ai.chat.schedule.create.failed')}: ${response.msg}`);
+ }
+ },
+ error: error => {
+ this.scheduleLoading = false;
+ console.error('Error creating schedule:', error);
+ this.message.error(this.i18nSvc.fanyi('ai.chat.schedule.create.failed'));
+ }
+ });
+ }
+
+ /**
+ * Toggle schedule enabled status
+ */
+ onToggleSchedule(schedule: SopSchedule): void {
+ if (!schedule.id) return;
+
+ this.aiChatService.toggleSchedule(schedule.id, schedule.enabled).subscribe({
+ next: response => {
+ if (response.code === 0) {
+ this.message.success(this.i18nSvc.fanyi('ai.chat.schedule.toggle.success'));
+ } else {
+ schedule.enabled = !schedule.enabled; // Revert on error
+ this.message.error(`${this.i18nSvc.fanyi('ai.chat.schedule.toggle.failed')}: ${response.msg}`);
+ }
+ },
+ error: error => {
+ schedule.enabled = !schedule.enabled; // Revert on error
+ console.error('Error toggling schedule:', error);
+ this.message.error(this.i18nSvc.fanyi('ai.chat.schedule.toggle.failed'));
+ }
+ });
+ }
+
+ /**
+ * Delete a schedule
+ */
+ onDeleteSchedule(schedule: SopSchedule): void {
+ if (!schedule.id) return;
+
+ this.modal.confirm({
+ nzTitle: this.i18nSvc.fanyi('ai.chat.conversation.delete.title'),
+ nzContent: `${this.i18nSvc.fanyi('ai.chat.conversation.delete.content')} "${schedule.sopName}"`,
+ nzOkText: this.i18nSvc.fanyi('ai.chat.conversation.delete.confirm'),
+ nzOkDanger: true,
+ nzOnOk: () => {
+ this.aiChatService.deleteSchedule(schedule.id!).subscribe({
+ next: response => {
+ if (response.code === 0) {
+ this.schedules = this.schedules.filter(s => s.id !== schedule.id);
+ this.message.success(this.i18nSvc.fanyi('ai.chat.schedule.delete.success'));
+ } else {
+ this.message.error(`${this.i18nSvc.fanyi('ai.chat.schedule.delete.failed')}: ${response.msg}`);
+ }
+ },
+ error: error => {
+ console.error('Error deleting schedule:', error);
+ this.message.error(this.i18nSvc.fanyi('ai.chat.schedule.delete.failed'));
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Start editing a schedule
+ */
+ onEditSchedule(schedule: SopSchedule): void {
+ this.editingSchedule = { ...schedule };
+ }
+
+ /**
+ * Cancel editing
+ */
+ onCancelEdit(): void {
+ this.editingSchedule = null;
+ }
+
+ /**
+ * Update an existing schedule
+ */
+ onUpdateSchedule(): void {
+ if (!this.editingSchedule || !this.editingSchedule.id) return;
+
+ this.scheduleLoading = true;
+ this.aiChatService.updateSchedule(this.editingSchedule.id, this.editingSchedule).subscribe({
+ next: response => {
+ this.scheduleLoading = false;
+ if (response.code === 0 && response.data) {
+ // Update the schedule in the list
+ const index = this.schedules.findIndex(s => s.id === this.editingSchedule!.id);
+ if (index !== -1) {
+ this.schedules[index] = response.data;
+ }
+ this.editingSchedule = null;
+ this.message.success(this.i18nSvc.fanyi('ai.chat.schedule.update.success'));
+ } else {
+ this.message.error(`${this.i18nSvc.fanyi('ai.chat.schedule.update.failed')}: ${response.msg}`);
+ }
+ },
+ error: error => {
+ this.scheduleLoading = false;
+ console.error('Error updating schedule:', error);
+ this.message.error(this.i18nSvc.fanyi('ai.chat.schedule.update.failed'));
+ }
+ });
+ }
}
diff --git a/web-app/src/assets/i18n/en-US.json b/web-app/src/assets/i18n/en-US.json
index 013c6c9b72..0fc3b86584 100644
--- a/web-app/src/assets/i18n/en-US.json
+++ b/web-app/src/assets/i18n/en-US.json
@@ -499,6 +499,7 @@
"common.button.ok": "OK",
"common.button.return": "Return",
"common.button.setting": "Setting",
+ "common.button.save": "Save",
"common.confirm.cancel": "Please confirm whether to cancel monitor!",
"common.confirm.cancel-batch": "Please confirm whether to cancel monitor in batches!",
"common.confirm.clear-cache": "Please confirm whether to clear cache!",
@@ -695,8 +696,8 @@
"log.stream.severity-number-placeholder": "Enter Severity Number",
"log.stream.severity-text": "Severity:",
"log.stream.severity-text-placeholder": "Enter Severity",
- "log.stream.content" : "Log Content",
- "log.stream.content-placeholder" : "Enter Log Content",
+ "log.stream.content": "Log Content",
+ "log.stream.content-placeholder": "Enter Log Content",
"log.stream.show-filters": "Show Filters",
"log.stream.span": "Span:",
"log.stream.span-id": "Span ID:",
@@ -1149,5 +1150,34 @@
"ai.chat.error.chat.response": "Failed to get AI response:",
"ai.chat.error.processing": "Sorry, there was an error processing your request. Please check if the AI Agent service is running and try again.",
"ai.chat.offline.mode": "AI Chat service unavailable. Running in offline mode.",
- "ai.chat.offline.response": "I apologize, but the AI Chat service is currently unavailable. Please ensure the HertzBeat AI module is running and try again later."
-}
+ "ai.chat.offline.response": "I apologize, but the AI Chat service is currently unavailable. Please ensure the HertzBeat AI module is running and try again later.",
+ "ai.chat.schedule.title": "Schedule Configuration",
+ "ai.chat.schedule.create": "Create Schedule",
+ "ai.chat.schedule.add": "Add New Task",
+ "ai.chat.schedule.configured": "Configured Schedules",
+ "ai.chat.schedule.skill": "Skill",
+ "ai.chat.schedule.skill.select": "Select Skill",
+ "ai.chat.schedule.skill.placeholder": "Select skill to execute",
+ "ai.chat.schedule.cron": "Execution Time",
+ "ai.chat.schedule.cron.label": "Execution Time (Cron Expression)",
+ "ai.chat.schedule.cron.help": "Format: seconds minutes hours day month week",
+ "ai.chat.schedule.cron.example.daily": "Example: 0 0 9 * * ? (daily at 9am)",
+ "ai.chat.schedule.cron.example.weekly": "Example: 0 0 9 ? * MON (Monday 9am)",
+ "ai.chat.schedule.cron.common": "Common: daily 9am",
+ "ai.chat.schedule.cron.monday": "Monday 9am",
+ "ai.chat.schedule.status": "Status",
+ "ai.chat.schedule.action": "Action",
+ "ai.chat.schedule.create.success": "Schedule created successfully",
+ "ai.chat.schedule.create.failed": "Failed to create schedule",
+ "ai.chat.schedule.delete.success": "Schedule deleted",
+ "ai.chat.schedule.delete.failed": "Failed to delete schedule",
+ "ai.chat.schedule.toggle.success": "Schedule status updated",
+ "ai.chat.schedule.toggle.failed": "Failed to toggle schedule",
+ "ai.chat.schedule.push": "Scheduled Push",
+ "ai.chat.schedule.button": "Schedule",
+ "ai.chat.schedule.tooltip": "Configure scheduled push tasks",
+ "ai.chat.schedule.edit": "Edit Schedule",
+ "ai.chat.schedule.skill": "Skill",
+ "ai.chat.schedule.update.success": "Schedule updated successfully",
+ "ai.chat.schedule.update.failed": "Failed to update schedule"
+}
\ No newline at end of file
diff --git a/web-app/src/assets/i18n/zh-CN.json b/web-app/src/assets/i18n/zh-CN.json
index d6ed897a01..320a1e0839 100644
--- a/web-app/src/assets/i18n/zh-CN.json
+++ b/web-app/src/assets/i18n/zh-CN.json
@@ -502,6 +502,7 @@
"common.button.ok": "确定",
"common.button.return": "返回",
"common.button.setting": "配置",
+ "common.button.save": "保存",
"common.confirm.cancel": "请确认是否取消!",
"common.confirm.cancel-batch": "请确认是否批量取消!",
"common.confirm.clear-cache": "请确认是否清理缓存!",
@@ -698,8 +699,8 @@
"log.stream.severity-number-placeholder": "输入日志级别编号",
"log.stream.severity-text": "日志级别:",
"log.stream.severity-text-placeholder": "输入日志级别",
- "log.stream.content" : "日志内容",
- "log.stream.content-placeholder" : "输入日志内容",
+ "log.stream.content": "日志内容",
+ "log.stream.content-placeholder": "输入日志内容",
"log.stream.show-filters": "显示过滤器",
"log.stream.span": "跨度:",
"log.stream.span-id": "Span ID:",
@@ -1152,5 +1153,34 @@
"ai.chat.error.chat.response": "获取 AI 回复失败:",
"ai.chat.error.processing": "处理您的请求时出错。请检查 AI 智能体服务是否正在运行,然后重试。",
"ai.chat.offline.mode": "AI 聊天服务不可用。正在离线模式下运行。",
- "ai.chat.offline.response": "抱歉,AI 聊天服务当前不可用。请确保 HertzBeat AI 模块正在运行,稍后再试。"
-}
+ "ai.chat.offline.response": "抱歉,AI 聊天服务当前不可用。请确保 HertzBeat AI 模块正在运行,稍后再试。",
+ "ai.chat.schedule.title": "定时任务设置",
+ "ai.chat.schedule.create": "创建定时任务",
+ "ai.chat.schedule.add": "添加新任务",
+ "ai.chat.schedule.configured": "已配置的定时任务",
+ "ai.chat.schedule.skill": "技能",
+ "ai.chat.schedule.skill.select": "选择技能",
+ "ai.chat.schedule.skill.placeholder": "选择要执行的技能",
+ "ai.chat.schedule.cron": "执行时间",
+ "ai.chat.schedule.cron.label": "执行时间 (Cron表达式)",
+ "ai.chat.schedule.cron.help": "格式: 秒 分 时 日 月 周",
+ "ai.chat.schedule.cron.example.daily": "示例: 0 0 9 * * ? (每天9点)",
+ "ai.chat.schedule.cron.example.weekly": "示例: 0 0 9 ? * MON (每周一9点)",
+ "ai.chat.schedule.cron.common": "常用: 每天9点",
+ "ai.chat.schedule.cron.monday": "每周一9点",
+ "ai.chat.schedule.status": "状态",
+ "ai.chat.schedule.action": "操作",
+ "ai.chat.schedule.create.success": "定时任务创建成功",
+ "ai.chat.schedule.create.failed": "创建定时任务失败",
+ "ai.chat.schedule.delete.success": "定时任务已删除",
+ "ai.chat.schedule.delete.failed": "删除定时任务失败",
+ "ai.chat.schedule.toggle.success": "定时任务状态已更新",
+ "ai.chat.schedule.toggle.failed": "更新定时任务状态失败",
+ "ai.chat.schedule.push": "定时推送",
+ "ai.chat.schedule.button": "定时任务",
+ "ai.chat.schedule.tooltip": "配置定时推送任务",
+ "ai.chat.schedule.edit": "编辑定时任务",
+ "ai.chat.schedule.skill": "技能",
+ "ai.chat.schedule.update.success": "定时任务更新成功",
+ "ai.chat.schedule.update.failed": "更新定时任务失败"
+}
\ No newline at end of file
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@hertzbeat.apache.org
For additional commands, e-mail: notifications-help@hertzbeat.apache.org