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 <[email protected]> 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: [email protected] For additional commands, e-mail: [email protected]
