From ef3cdf4bdc89db2952c75944eb15b7c923bcac58 Mon Sep 17 00:00:00 2001 From: sunny-335 Date: Sun, 14 Jun 2026 23:07:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8F=AF=E9=9D=A0=E6=80=A7=E4=B8=8E=E5=8F=AF=E7=94=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读 - 实现失败重试机制,指数退避策略 - 评论者黑名单支持邮箱匹配,可从评论列表选择 - 日志页面支持按状态/情感筛选和关键词搜索 - AI角色邮箱配置增加 Gravatar 头像实时预览 - 插件依赖声明修正:ai-foundation 改为必须依赖 - 新增定时清理旧记录功能,可配置保留天数 - 更新 README 和 VitePress 文档 --- README.md | 62 +++-- docs/.vitepress/config.ts | 1 + docs/guide/auto-reply.md | 13 + docs/guide/cleanup.md | 28 ++ docs/guide/draft-mode.md | 15 ++ docs/guide/faq.md | 16 ++ docs/guide/filter.md | 13 +- docs/guide/introduction.md | 9 + docs/guide/persona.md | 11 + docs/guide/settings.md | 13 +- docs/index.md | 10 +- .../endpoint/CommentAiAutopilotEndpoint.java | 99 ++++++- .../service/AiReplyCleanupService.java | 136 ++++++++++ .../service/AiReplyOrchestrator.java | 198 ++++++++++++-- .../service/FilterService.java | 15 +- .../service/PromptBuilder.java | 58 ++-- src/main/resources/extensions/settings.yaml | 16 +- src/main/resources/plugin.yaml | 2 +- ui/src/views/LogsView.vue | 59 +++- ui/src/views/SettingsView.vue | 253 +++++++++++++++++- 20 files changed, 946 insertions(+), 81 deletions(-) create mode 100644 docs/guide/cleanup.md create mode 100644 src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java diff --git a/README.md b/README.md index c1d7bfd..35becc3 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,66 @@ -# comment-ai-autopilot +# AI回评 / Comment AI Autopilot -comment-ai-autopilot - Halo 插件 +基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复。 -## 简介 +## 功能特性 -这是一个基于 Halo 的插件项目。 +- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 +- **多语言适配** — 根据评论语言自动用对应语言回复 +- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 +- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持批量操作 +- **失败重试** — AI生成失败时自动重试,指数退避策略 +- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 +- **评论者黑名单** — 支持按名称和邮箱屏蔽指定评论者,可从评论列表选择 +- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 +- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,设置页面实时预览头像 +- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 +- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 +- **日志筛选** — 按状态、情感筛选,关键词搜索 +- **数据清理** — 自动清理超过指定天数的旧记录 +- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 -## 开发环境 +## 前置要求 -- Java 21+ -- Node.js 18+ -- pnpm +- Halo 2.23+ +- AI Foundation 插件(必须) + +## 安装 + +1. 前往 [Releases](https://github.com/暖心向阳335/comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 +2. 登录 Halo 管理后台 +3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮 +4. 选择下载的 `.jar` 文件上传 +5. 安装完成后启用插件 + +## 从源码构建 + +```bash +# 克隆仓库 +git clone https://github.com/暖心向阳335/comment-ai-autopilot.git +cd plugin-comment-ai-autopilot + +# 构建 +./gradlew build -x test + +# 构建产物位于 build/libs/ 目录 +``` ## 开发 ```bash -# 启用插件 +# 启用插件开发服务器 ./gradlew haloServer + # 开发前端 cd ui pnpm install pnpm dev ``` -## 构建 +## 文档 -```bash -./gradlew build -``` - -构建完成后,可以在 `build/libs` 目录找到插件 jar 文件。 +完整文档请访问 [AI回评文档站](https://暖心向阳335.github.io/comment-ai-autopilot/) ## 许可证 -[GPL-3.0](./LICENSE) © 暖心向阳335 \ No newline at end of file +[GPL-3.0](./LICENSE) © 暖心向阳335 diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 29d3864..38718ab 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,6 +25,7 @@ export default defineConfig({ { text: "情感分析", link: "/guide/sentiment" }, { text: "过滤规则", link: "/guide/filter" }, { text: "手动触发", link: "/guide/manual-trigger" }, + { text: "数据清理", link: "/guide/cleanup" }, ], }, { diff --git a/docs/guide/auto-reply.md b/docs/guide/auto-reply.md index 8dad91c..854bf4e 100644 --- a/docs/guide/auto-reply.md +++ b/docs/guide/auto-reply.md @@ -32,3 +32,16 @@ | 自动回复 | 是否启用自动回复功能 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | + +## 重试机制 + +当AI生成失败(如服务不可用、生成空内容、审核不通过)时,插件会自动重试: + +1. 每次重试递增 `retryCount` +2. 重试间隔采用指数退避策略:第1次等5秒,第2次等15秒,第3次等30秒 +3. 超过最大重试次数后标记为最终失败(FAIL) +4. 重试期间记录状态为 PENDING + +::: tip +重试次数由 **最大重试次数** 配置项控制,默认为3次。 +::: diff --git a/docs/guide/cleanup.md b/docs/guide/cleanup.md new file mode 100644 index 0000000..c86f9da --- /dev/null +++ b/docs/guide/cleanup.md @@ -0,0 +1,28 @@ +# 数据清理 + +数据清理功能可以自动删除过期的AI回复记录,防止数据库无限增长。 + +## 自动清理 + +插件每天自动执行一次清理任务,删除超过保留天数的 `AiCommentReply` 记录。 + +### 配置 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| 启用自动清理 | 是否开启自动清理 | 开启 | +| 保留天数 | 超过此天数的记录将被清理 | 30 | + +配置路径:**插件设置** → **数据清理** + +## 手动清理 + +在数据清理页面点击 **立即清理** 按钮,可以立即执行一次清理操作。清理完成后会显示删除的记录数量。 + +::: warning +清理操作不可撤销,请根据实际需求设置合理的保留天数。 +::: + +## 清理范围 + +清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。已发布的评论不受影响。 diff --git a/docs/guide/draft-mode.md b/docs/guide/draft-mode.md index 3609856..d86ff5a 100644 --- a/docs/guide/draft-mode.md +++ b/docs/guide/draft-mode.md @@ -22,3 +22,18 @@ - 草稿记录显示 **审核通过** 和 **拒绝** 按钮 - 已发布的记录显示正常状态 - 被拒绝的记录显示 REJECTED 标签 + +## 批量操作 + +当日志页面有多条草稿记录时,可以使用批量操作功能: + +1. 勾选要操作的记录(支持全选) +2. 选中后顶部显示批量操作工具栏 +3. 支持的批量操作: + - **批量通过** — 一次性审核通过多条草稿 + - **批量拒绝** — 一次性拒绝多条草稿 + - **批量删除** — 一次性删除多条记录 + +::: warning +批量操作不可撤销,请谨慎操作。 +::: diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 62f88ab..8724a69 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -50,3 +50,19 @@ ## 插件升级后设置丢失了? 插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。 + +## AI生成失败后会怎样? + +插件会自动重试,重试次数由"最大重试次数"配置控制(默认3次)。重试间隔递增(5秒、15秒、30秒)。超过最大重试次数后标记为失败。 + +## 如何批量审核草稿回复? + +在AI回复日志页面,勾选多条记录后,使用顶部的批量操作工具栏进行批量通过、拒绝或删除。 + +## 旧记录太多怎么办? + +在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。 + +## 黑名单支持邮箱吗? + +支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。 diff --git a/docs/guide/filter.md b/docs/guide/filter.md index 18abc4c..45ae346 100644 --- a/docs/guide/filter.md +++ b/docs/guide/filter.md @@ -32,13 +32,20 @@ 1. 进入插件设置页面 2. 在 **基本设置** 中找到 **评论者黑名单** -3. 输入评论者的显示名称,多个用逗号分隔 +3. 输入评论者的显示名称或邮箱,多个用逗号分隔 4. 保存设置 +### 从评论列表选择 + +1. 在黑名单输入框旁点击 **添加评论者** 按钮 +2. 弹出评论者列表对话框 +3. 搜索并选择要屏蔽的评论者 +4. 选中后自动添加到黑名单 + ### 示例 ``` -张三,李四,王五 +张三,spam@example.com,李四 ``` -黑名单中的评论者发布评论时,插件会跳过AI回复,并在日志中记录过滤原因。 +黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 03f77c5..5c5475d 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -5,19 +5,27 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 ## 核心功能 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 +- **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 - **草稿模式** — AI回复先存为草稿,管理员审核后再发布 +- **失败重试** — AI生成失败时自动重试,指数退避策略 +- **批量操作** — 草稿模式下支持批量通过/拒绝/删除 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 - **评论者黑名单** — 屏蔽指定评论者,不触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复 - **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像 - **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 +- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 +- **日志筛选搜索** — 按状态、情感筛选,关键词搜索 +- **数据清理** — 自动清理超过指定天数的旧记录 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 ## 工作流程 ``` 新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿 + ↓ (失败) + 重试 → ... → 最终失败 ``` 1. **新评论到达** — Reconciler 监听到新评论创建事件 @@ -27,6 +35,7 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够 5. **AI生成** — 调用AI模型生成回复内容 6. **安全审核** — 对生成内容进行安全审核 7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核 +8. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增 ## 前置要求 diff --git a/docs/guide/persona.md b/docs/guide/persona.md index 7cf3195..1b1ce63 100644 --- a/docs/guide/persona.md +++ b/docs/guide/persona.md @@ -37,3 +37,14 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用 ::: warning 如果不填写邮箱,AI回复者将使用 Halo 默认头像。 ::: + +### 头像预览 + +在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。 + +::: tip +如果预览头像不正确,请检查: +1. 邮箱是否拼写正确 +2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像 +3. 头像更新可能有缓存延迟 +::: diff --git a/docs/guide/settings.md b/docs/guide/settings.md index a7ee4d8..3967c54 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -9,7 +9,7 @@ | 自动回复 | 是否启用自动回复功能 | 开启 | | 自动发布 | AI回复是否自动发布 | 开启 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 | -| 评论者黑名单 | 不触发AI回复的评论者显示名称,逗号分隔 | 空 | +| 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 | ## AI角色设置 @@ -53,3 +53,14 @@ | `{{article}}` | 文章内容 | | `{{comment}}` | 评论内容 | | `{{conversation}}` | 对话上下文(多轮对话时) | + +## 数据清理 + +| 配置项 | 说明 | 默认值 | +|--------|------|--------| +| 启用自动清理 | 是否自动清理过期的AI回复记录 | 开启 | +| 保留天数 | 超过此天数的记录将被自动清理 | 30 | + +::: tip +你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。 +::: diff --git a/docs/index.md b/docs/index.md index 5458330..4860314 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,11 +15,15 @@ hero: features: - title: 自动回复 - details: 监听新评论,自动调用AI生成回复,支持对话式上下文 + details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试 + - title: 多语言适配 + details: 根据评论语言自动用对应语言回复,中文评论中文回复,英文评论英文回复 - title: 情感分析 details: 分析评论情感倾向,根据正面/中性/负面调整回复语气 - title: 草稿模式 - details: AI回复先存为草稿,管理员审核后再发布 + details: AI回复先存为草稿,管理员审核后再发布,支持批量操作 - title: 灵活过滤 - details: 文章/页面级开关控制,评论者黑名单 + details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配 + - title: 数据管理 + details: 仪表盘统计、日志筛选搜索、自动清理旧记录 --- diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 60c21d4..8b8245e 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -16,6 +16,7 @@ import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.PageRequestImpl; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; +import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import com.fasterxml.jackson.databind.JsonNode; @@ -29,8 +30,10 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.springframework.web.reactive.function.server.RouterFunctions.route; @@ -40,13 +43,15 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final AiReplyOrchestrator orchestrator; + private final AiReplyCleanupService cleanupService; private final ObjectMapper objectMapper; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; - public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) { + public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService) { this.client = client; this.orchestrator = orchestrator; + this.cleanupService = cleanupService; this.objectMapper = new ObjectMapper(); } @@ -65,6 +70,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .POST("/replies/{name}/reject", this::rejectReply) .POST("/comments/{commentName}/trigger", this::triggerReply) .POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply) + .GET("/commenters", this::listCommenters) + .POST("/cleanup", this::triggerCleanup) .build(); } @@ -76,10 +83,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private Mono listReplies(ServerRequest request) { var page = Integer.parseInt(request.queryParam("page").orElse("1")); var size = Integer.parseInt(request.queryParam("size").orElse("20")); - var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp")); - var pageable = PageRequestImpl.of(page, size, sort); + var statusFilter = request.queryParam("status").orElse(""); + var sentimentFilter = request.queryParam("sentiment").orElse(""); + var keywordFilter = request.queryParam("keyword").orElse(""); - return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable) + return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) + .collectList() + .map(replies -> { + var filtered = replies.stream() + .filter(r -> { + if (!statusFilter.isBlank() + && !statusFilter.equals(r.getSpec().getStatus())) { + return false; + } + if (!sentimentFilter.isBlank() + && !sentimentFilter.equals(r.getSpec().getSentiment())) { + return false; + } + if (!keywordFilter.isBlank()) { + String reply = r.getSpec().getReply(); + if (reply == null || !reply.contains(keywordFilter)) { + return false; + } + } + return true; + }) + .sorted(Comparator.comparing( + (AiCommentReply r) -> r.getMetadata().getCreationTimestamp(), + Comparator.nullsLast(Comparator.reverseOrder()) + )) + .toList(); + + int total = filtered.size(); + int fromIndex = (page - 1) * size; + int toIndex = Math.min(fromIndex + size, total); + List pageContent = fromIndex < total + ? filtered.subList(fromIndex, toIndex) : List.of(); + + Map result = new HashMap<>(); + result.put("items", pageContent); + result.put("total", total); + result.put("page", page); + result.put("size", size); + result.put("totalPages", (int) Math.ceil((double) total / size)); + result.put("first", page == 1); + result.put("last", toIndex >= total); + return result; + }) .flatMap(result -> ServerResponse.ok().bodyValue(result)); } @@ -495,4 +545,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { String time, boolean isAi ) {} + + public record CommenterInfo( + String displayName, + String email + ) {} + + private Mono listCommenters(ServerRequest request) { + return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted()) + .collectList() + .map(comments -> { + Set seen = new HashSet<>(); + List result = new ArrayList<>(); + for (var comment : comments) { + var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null; + if (owner == null) continue; + String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : ""; + String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null + ? owner.getName() : ""; + String key = displayName.toLowerCase() + "|" + email.toLowerCase(); + if (seen.add(key)) { + result.add(new CommenterInfo(displayName, email)); + } + } + return result; + }) + .flatMap(commenters -> ServerResponse.ok().bodyValue(commenters)); + } + + private Mono triggerCleanup(ServerRequest request) { + return Mono.fromCallable(() -> { + int retentionDays = cleanupService.getRetentionDays(); + long deleted = cleanupService.executeCleanup(retentionDays); + return Map.of("deletedCount", deleted, "retentionDays", retentionDays); + }) + .flatMap(result -> ServerResponse.ok().bodyValue(result)) + .onErrorResume(e -> { + log.warn("Failed to trigger cleanup: {}", e.getMessage()); + return ServerResponse.status(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR) + .bodyValue(Map.of("message", "清理失败: " + e.getMessage())); + }); + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java new file mode 100644 index 0000000..af233d1 --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java @@ -0,0 +1,136 @@ +package top.nxxy335.commentaiautopilot.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.stereotype.Component; +import org.springframework.data.domain.Sort; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.ReactiveExtensionClient; +import top.nxxy335.commentaiautopilot.extension.AiCommentReply; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +public class AiReplyCleanupService implements DisposableBean { + + private final ReactiveExtensionClient client; + private final ObjectMapper objectMapper; + private final ScheduledExecutorService scheduler; + + private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; + + public AiReplyCleanupService(ReactiveExtensionClient client) { + this.client = client; + this.objectMapper = new ObjectMapper(); + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "ai-reply-cleanup"); + t.setDaemon(true); + return t; + }); + // Schedule daily cleanup: initial delay 1 minute, then every 24 hours + this.scheduler.scheduleAtFixedRate(this::dailyCleanup, 1, 24 * 60, TimeUnit.MINUTES); + } + + public void dailyCleanup() { + try { + Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return false; + String cleanupJson = data.get("cleanup"); + if (cleanupJson == null || cleanupJson.isBlank()) return true; + try { + JsonNode node = objectMapper.readTree(cleanupJson); + return node.has("cleanupEnabled") && node.get("cleanupEnabled").asBoolean(true); + } catch (Exception e) { + log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage()); + return true; + } + }) + .defaultIfEmpty(true) + .block(); + + if (!Boolean.TRUE.equals(enabled)) { + log.debug("[Cleanup] Auto cleanup is disabled, skipping"); + return; + } + + int retentionDays = getRetentionDays(); + long deleted = executeCleanup(retentionDays); + log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays); + } catch (Exception e) { + log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e); + } + } + + public long executeCleanup(int retentionDays) { + Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS); + + var oldRecords = client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) + .filter(r -> { + Instant created = r.getMetadata().getCreationTimestamp(); + return created != null && created.isBefore(cutoff); + }) + .collectList() + .block(); + + if (oldRecords == null || oldRecords.isEmpty()) { + return 0; + } + + long deleted = 0; + for (var record : oldRecords) { + try { + client.delete(record).block(); + deleted++; + } catch (Exception e) { + log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage()); + } + } + return deleted; + } + + public int getRetentionDays() { + try { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return 30; + String cleanupJson = data.get("cleanup"); + if (cleanupJson == null || cleanupJson.isBlank()) return 30; + try { + JsonNode node = objectMapper.readTree(cleanupJson); + return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30; + } catch (Exception e) { + return 30; + } + }) + .defaultIfEmpty(30) + .block(); + } catch (Exception e) { + log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage()); + return 30; + } + } + + @Override + public void destroy() { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index d9e8942..e0288ab 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -1,14 +1,15 @@ package top.nxxy335.commentaiautopilot.service; -import lombok.RequiredArgsConstructor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.plugin.ReactiveSettingFetcher; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import java.time.Duration; @@ -17,9 +18,10 @@ import java.util.concurrent.ConcurrentHashMap; @Component @Slf4j -@RequiredArgsConstructor public class AiReplyOrchestrator { + private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; + private final ContextExtractor contextExtractor; private final PromptBuilder promptBuilder; private final AiReplyService aiReplyService; @@ -28,12 +30,31 @@ public class AiReplyOrchestrator { private final CommentReplyPublisher commentReplyPublisher; private final FilterService filterService; private final ReactiveExtensionClient client; - private final ReactiveSettingFetcher settingFetcher; + private final ObjectMapper objectMapper; // In-memory dedup: tracks which comment/reply is currently being processed // Prevents duplicate replies when Reconciler fires multiple times private final ConcurrentHashMap processingLocks = new ConcurrentHashMap<>(); + public AiReplyOrchestrator(ContextExtractor contextExtractor, + PromptBuilder promptBuilder, + AiReplyService aiReplyService, + SentimentService sentimentService, + ReviewService reviewService, + CommentReplyPublisher commentReplyPublisher, + FilterService filterService, + ReactiveExtensionClient client) { + this.contextExtractor = contextExtractor; + this.promptBuilder = promptBuilder; + this.aiReplyService = aiReplyService; + this.sentimentService = sentimentService; + this.reviewService = reviewService; + this.commentReplyPublisher = commentReplyPublisher; + this.filterService = filterService; + this.client = client; + this.objectMapper = new ObjectMapper(); + } + /** * Process a new comment or reply. * @@ -151,6 +172,7 @@ public class AiReplyOrchestrator { /** * Generate AI reply, optionally review it, then publish. + * Includes retry logic for empty AI replies and review failures. */ private Mono generateAndPublish(String prompt, ContextExtractor.CommentContext context, AiCommentReply replyRecord, String modelName) { @@ -159,7 +181,7 @@ public class AiReplyOrchestrator { .flatMap(aiReply -> { if (aiReply.isBlank()) { log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId()); - return updateRecord(replyRecord, "", 0, "FAIL", false).then(); + return retryOrFail(replyRecord, context, modelName, "AI generated empty reply"); } log.info("[Orchestrator] AI generated reply for {}: {} chars", @@ -172,7 +194,9 @@ public class AiReplyOrchestrator { if ("FAIL".equals(reviewResult.status())) { log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing", context.commentId()); - return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then(); + // Save the failed reply content, then retry + return updateRecord(replyRecord, aiReply, 0, "FAIL", false) + .then(retryOrFail(replyRecord, context, modelName, "Content safety review failed")); } return publishReply(context, aiReply, replyRecord, reviewResult.score()); }) @@ -186,6 +210,73 @@ public class AiReplyOrchestrator { }); } + /** + * Decide whether to retry or mark as final FAIL. + * If retryCount < maxRetryCount, increment retryCount, set status to PENDING, + * delay with exponential backoff, then re-execute the generate+review+publish flow. + * Otherwise, mark as final FAIL. + */ + private Mono retryOrFail(AiCommentReply replyRecord, + ContextExtractor.CommentContext context, + String modelName, + String reason) { + return getMaxRetryCount().flatMap(maxRetry -> { + int currentRetryCount = replyRecord.getSpec().getRetryCount() != null + ? replyRecord.getSpec().getRetryCount() : 0; + + if (currentRetryCount < maxRetry) { + int newRetryCount = currentRetryCount + 1; + long delaySeconds = 5L * (1L << currentRetryCount); // 5 * 2^retryCount + log.info("[Orchestrator] Retrying ({}/{}) for {} after {}s, reason: {}", + newRetryCount, maxRetry, context.commentId(), delaySeconds, reason); + + // Update retryCount and reset status to PENDING + return updateRecordForRetry(replyRecord, newRetryCount) + .delayElement(Duration.ofSeconds(delaySeconds)) + .then(retryGenerate(context, replyRecord, modelName)); + } else { + log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}", + maxRetry, context.commentId(), reason); + return updateRecord(replyRecord, "", 0, "FAIL", false).then(); + } + }); + } + + /** + * Re-execute the core generate+review+publish flow for a retry. + * Rebuilds the prompt from context and sentiment, then calls generateAndPublish again. + */ + private Mono retryGenerate(ContextExtractor.CommentContext context, + AiCommentReply replyRecord, + String modelName) { + return sentimentService.analyzeSentiment(context.commentContent(), modelName) + .flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment()) + .flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName)) + ); + } + + /** + * Update the record's retryCount and reset status to PENDING for a retry attempt. + */ + private Mono updateRecordForRetry(AiCommentReply record, int newRetryCount) { + return client.fetch(AiCommentReply.class, record.getMetadata().getName()) + .flatMap(latest -> { + latest.getSpec().setRetryCount(newRetryCount); + latest.getSpec().setStatus("PENDING"); + latest.getSpec().setReply(""); + latest.getSpec().setScore(0); + latest.getSpec().setPublished(false); + return client.update(latest); + }) + .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) + .filter(e -> e instanceof OptimisticLockingFailureException) + .doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying retry-update for {} due to optimistic lock", + record.getMetadata().getName())) + ) + .doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated for retry: retryCount={}", + record.getMetadata().getName(), newRetryCount)); + } + /** * Publish the reply and update the record to PASS + published=true. */ @@ -205,41 +296,104 @@ public class AiReplyOrchestrator { } private Mono getModelName() { - return settingFetcher.getSettingValue("model") - .map(node -> { - var nameNode = node.get("modelName"); - if (nameNode != null && !nameNode.asText().isBlank()) { - return nameNode.asText(); + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String modelJson = data.get("model"); + if (modelJson == null || modelJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(modelJson); + JsonNode nameNode = node.get("modelName"); + if (nameNode != null && !nameNode.asText().isBlank()) { + return nameNode.asText(); + } + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse modelName from ConfigMap: {}", e.getMessage()); } - return ""; + return null; }) .onErrorResume(e -> { - log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage()); - return Mono.just(""); + log.debug("[Orchestrator] Failed to fetch model setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); }) .defaultIfEmpty(""); } private Mono isAutoReplyEnabled() { - return settingFetcher.getSettingValue("basic") - .map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true)) + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (!node.has("autoReply")) { + return true; + } + return node.get("autoReply").asBoolean(true); + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse autoReply from ConfigMap: {}", e.getMessage()); + return null; + } + }) .onErrorResume(e -> { - log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage()); - return Mono.just(true); + log.debug("[Orchestrator] Failed to fetch autoReply setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); }) .defaultIfEmpty(true); } private Mono isAutoPublishEnabled() { - return settingFetcher.getSettingValue("basic") - .map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true)) + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (!node.has("autoPublish")) { + return true; + } + return node.get("autoPublish").asBoolean(true); + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse autoPublish from ConfigMap: {}", e.getMessage()); + return null; + } + }) .onErrorResume(e -> { - log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage()); - return Mono.just(true); + log.debug("[Orchestrator] Failed to fetch autoPublish setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); }) .defaultIfEmpty(true); } + private Mono getMaxRetryCount() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (node.has("maxRetryCount")) { + return node.get("maxRetryCount").asInt(3); + } + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse maxRetryCount from ConfigMap: {}", e.getMessage()); + } + return null; + }) + .onErrorResume(e -> { + log.debug("[Orchestrator] Failed to fetch maxRetryCount setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); + }) + .defaultIfEmpty(3); + } + private Mono createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) { AiCommentReply record = new AiCommentReply(); record.setMetadata(new Metadata()); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java index 3bc6fe2..6a3db54 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java @@ -70,8 +70,9 @@ public class FilterService { ? node.get("blockedCommenters").asText("") : ""; List blockedCommenters = parseList(blockedCommentersStr); String commenterName = getCommenterDisplayName(comment); - if (isInList(commenterName, blockedCommenters)) { - log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName); + String commenterEmail = getCommenterEmail(comment); + if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) { + log.info("[Filter] Commenter '{}' (email: '{}') is in blocked list, skipping", commenterName, commenterEmail); return true; } return false; @@ -130,6 +131,16 @@ public class FilterService { return displayName != null ? displayName : ""; } + private String getCommenterEmail(Comment comment) { + if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return ""; + var owner = comment.getSpec().getOwner(); + if ("EMAIL".equals(owner.getKind())) { + var name = owner.getName(); + return name != null ? name : ""; + } + return ""; + } + private List parseList(String str) { if (str == null || str.isBlank()) return Collections.emptyList(); return Arrays.stream(str.split(",")) diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index 1f957ac..bb97c1d 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -1,17 +1,25 @@ package top.nxxy335.commentaiautopilot.service; -import lombok.RequiredArgsConstructor; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; -import run.halo.app.plugin.ReactiveSettingFetcher; +import run.halo.app.extension.ConfigMap; +import run.halo.app.extension.ReactiveExtensionClient; @Component @Slf4j -@RequiredArgsConstructor public class PromptBuilder { - private final ReactiveSettingFetcher settingFetcher; + private final ReactiveExtensionClient client; + private final ObjectMapper objectMapper; + private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; + + public PromptBuilder(ReactiveExtensionClient client) { + this.client = client; + this.objectMapper = new ObjectMapper(); + } private static final String SAFETY_PROMPT = """ 【安全规范】 @@ -76,13 +84,22 @@ public class PromptBuilder { } private Mono getPromptTemplate() { - return settingFetcher.getSettingValue("prompt") - .map(node -> { - var templateNode = node.get("customPromptTemplate"); - if (templateNode != null && !templateNode.asText().isBlank()) { - return templateNode.asText(); + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String promptJson = data.get("prompt"); + if (promptJson == null || promptJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(promptJson); + JsonNode templateNode = node.get("customPromptTemplate"); + if (templateNode != null && !templateNode.asText().isBlank()) { + return templateNode.asText(); + } + } catch (Exception e) { + log.warn("Failed to parse customPromptTemplate from ConfigMap: {}", e.getMessage()); } - return DEFAULT_PROMPT_TEMPLATE; + return null; }) .onErrorResume(e -> { log.debug("Failed to fetch prompt template setting: {}", e.getMessage()); @@ -92,13 +109,22 @@ public class PromptBuilder { } private Mono getPersonaPrompt() { - return settingFetcher.getSettingValue("persona") - .map(node -> { - var promptNode = node.get("personaPrompt"); - if (promptNode != null && !promptNode.asText().isBlank()) { - return promptNode.asText(); + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String personaJson = data.get("persona"); + if (personaJson == null || personaJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(personaJson); + JsonNode promptNode = node.get("personaPrompt"); + if (promptNode != null && !promptNode.asText().isBlank()) { + return promptNode.asText(); + } + } catch (Exception e) { + log.warn("Failed to parse personaPrompt from ConfigMap: {}", e.getMessage()); } - return DEFAULT_PERSONA_PROMPT; + return null; }) .onErrorResume(e -> { log.debug("Failed to fetch persona prompt setting: {}", e.getMessage()); diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index e1b6bb2..4930b35 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -24,7 +24,7 @@ spec: - $formkit: textarea name: blockedCommenters label: 评论者黑名单 - help: 输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复 + help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复 value: "" - group: persona label: AI角色设置 @@ -57,4 +57,18 @@ spec: name: customPromptTemplate label: 自定义Prompt模板 value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}" + - group: cleanup + label: 数据清理 + formSchema: + - $formkit: switch + name: cleanupEnabled + label: 启用自动清理 + value: true + - $formkit: number + name: retentionDays + label: 保留天数 + help: 超过此天数的AI回复记录将被自动清理 + value: 30 + min: 1 + max: 365 diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 30a4367..a0a84d8 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -24,4 +24,4 @@ spec: configMapName: "comment-ai-autopilot-configmap" version: "0.0.1-w5s2t7" pluginDependencies: - ai-foundation?: "*" + ai-foundation: "*" diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index 25da154..464a278 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -38,6 +38,41 @@ + +
+ + + + +
+
@@ -312,6 +347,11 @@ const totalPages = ref(0) const selectedNames = ref>(new Set()) const selectAll = ref(false) +// Filter state +const filterStatus = ref("") +const filterSentiment = ref("") +const filterKeyword = ref("") + const toggleSelect = (name: string) => { if (selectedNames.value.has(name)) { selectedNames.value.delete(name) @@ -340,9 +380,13 @@ const conversationMessages = ref([]) const fetchReplies = async () => { loading.value = true try { + const params: Record = { page: page.value, size: size.value } + if (filterStatus.value) params.status = filterStatus.value + if (filterSentiment.value) params.sentiment = filterSentiment.value + if (filterKeyword.value) params.keyword = filterKeyword.value const { data } = await axiosInstance.get( "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", - { params: { page: page.value, size: size.value } }, + { params }, ) replies.value = data.items || [] total.value = data.total || 0 @@ -561,6 +605,19 @@ const renderContent = (content: string) => { .replace(/ { + filterStatus.value = "" + filterSentiment.value = "" + filterKeyword.value = "" + page.value = 1 + fetchReplies() +} + +watch([filterStatus, filterSentiment, filterKeyword], () => { + page.value = 1 + fetchReplies() +}) + watch(page, () => { selectedNames.value.clear() selectAll.value = false diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index 48d0ae5..f7049a3 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -62,8 +62,13 @@ />
- -
输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复
+
+ + + 添加评论者 + +
+
输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复