feat: 增强插件可靠性与可用性

- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读
- 实现失败重试机制,指数退避策略
- 评论者黑名单支持邮箱匹配,可从评论列表选择
- 日志页面支持按状态/情感筛选和关键词搜索
- AI角色邮箱配置增加 Gravatar 头像实时预览
- 插件依赖声明修正:ai-foundation 改为必须依赖
- 新增定时清理旧记录功能,可配置保留天数
- 更新 README 和 VitePress 文档
This commit is contained in:
sunny-335
2026-06-14 23:07:22 +08:00
parent e03ba7a20d
commit ef3cdf4bdc
20 changed files with 946 additions and 81 deletions
+45 -15
View File
@@ -1,35 +1,65 @@
# 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+ - Halo 2.23+
- Node.js 18+ - AI Foundation 插件(必须)
- pnpm
## 安装
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 ```bash
# 启用插件 # 启用插件开发服务器
./gradlew haloServer ./gradlew haloServer
# 开发前端 # 开发前端
cd ui cd ui
pnpm install pnpm install
pnpm dev pnpm dev
``` ```
## 构建 ## 文档
```bash 完整文档请访问 [AI回评文档站](https://暖心向阳335.github.io/comment-ai-autopilot/)
./gradlew build
```
构建完成后,可以在 `build/libs` 目录找到插件 jar 文件。
## 许可证 ## 许可证
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig({
{ text: "情感分析", link: "/guide/sentiment" }, { text: "情感分析", link: "/guide/sentiment" },
{ text: "过滤规则", link: "/guide/filter" }, { text: "过滤规则", link: "/guide/filter" },
{ text: "手动触发", link: "/guide/manual-trigger" }, { text: "手动触发", link: "/guide/manual-trigger" },
{ text: "数据清理", link: "/guide/cleanup" },
], ],
}, },
{ {
+13
View File
@@ -32,3 +32,16 @@
| 自动回复 | 是否启用自动回复功能 | 开启 | | 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 | | 自动发布 | AI回复是否自动发布,关闭则存为草稿 | 开启 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
## 重试机制
当AI生成失败(如服务不可用、生成空内容、审核不通过)时,插件会自动重试:
1. 每次重试递增 `retryCount`
2. 重试间隔采用指数退避策略:第1次等5秒,第2次等15秒,第3次等30秒
3. 超过最大重试次数后标记为最终失败(FAIL)
4. 重试期间记录状态为 PENDING
::: tip
重试次数由 **最大重试次数** 配置项控制,默认为3次。
:::
+28
View File
@@ -0,0 +1,28 @@
# 数据清理
数据清理功能可以自动删除过期的AI回复记录,防止数据库无限增长。
## 自动清理
插件每天自动执行一次清理任务,删除超过保留天数的 `AiCommentReply` 记录。
### 配置
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 启用自动清理 | 是否开启自动清理 | 开启 |
| 保留天数 | 超过此天数的记录将被清理 | 30 |
配置路径:**插件设置** → **数据清理**
## 手动清理
在数据清理页面点击 **立即清理** 按钮,可以立即执行一次清理操作。清理完成后会显示删除的记录数量。
::: warning
清理操作不可撤销,请根据实际需求设置合理的保留天数。
:::
## 清理范围
清理操作仅删除 `AiCommentReply` 记录(插件内部的日志记录),不会删除已发布的 Halo Reply 评论。已发布的评论不受影响。
+15
View File
@@ -22,3 +22,18 @@
- 草稿记录显示 **审核通过****拒绝** 按钮 - 草稿记录显示 **审核通过****拒绝** 按钮
- 已发布的记录显示正常状态 - 已发布的记录显示正常状态
- 被拒绝的记录显示 REJECTED 标签 - 被拒绝的记录显示 REJECTED 标签
## 批量操作
当日志页面有多条草稿记录时,可以使用批量操作功能:
1. 勾选要操作的记录(支持全选)
2. 选中后顶部显示批量操作工具栏
3. 支持的批量操作:
- **批量通过** — 一次性审核通过多条草稿
- **批量拒绝** — 一次性拒绝多条草稿
- **批量删除** — 一次性删除多条记录
::: warning
批量操作不可撤销,请谨慎操作。
:::
+16
View File
@@ -50,3 +50,19 @@
## 插件升级后设置丢失了? ## 插件升级后设置丢失了?
插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。 插件升级不会丢失设置。如果遇到问题,请检查 ConfigMap 是否正确迁移。
## AI生成失败后会怎样?
插件会自动重试,重试次数由"最大重试次数"配置控制(默认3次)。重试间隔递增(5秒、15秒、30秒)。超过最大重试次数后标记为失败。
## 如何批量审核草稿回复?
在AI回复日志页面,勾选多条记录后,使用顶部的批量操作工具栏进行批量通过、拒绝或删除。
## 旧记录太多怎么办?
在插件设置的"数据清理"页面,可以配置自动清理超过指定天数的记录(默认30天),也可以点击"立即清理"手动触发。
## 黑名单支持邮箱吗?
支持。黑名单同时匹配评论者的显示名称和邮箱地址,不区分大小写。你也可以在设置页面点击"添加评论者"按钮从评论列表中选择。
+10 -3
View File
@@ -32,13 +32,20 @@
1. 进入插件设置页面 1. 进入插件设置页面
2.**基本设置** 中找到 **评论者黑名单** 2.**基本设置** 中找到 **评论者黑名单**
3. 输入评论者的显示名称,多个用逗号分隔 3. 输入评论者的显示名称或邮箱,多个用逗号分隔
4. 保存设置 4. 保存设置
### 从评论列表选择
1. 在黑名单输入框旁点击 **添加评论者** 按钮
2. 弹出评论者列表对话框
3. 搜索并选择要屏蔽的评论者
4. 选中后自动添加到黑名单
### 示例 ### 示例
``` ```
张三,李四,王五 张三,spam@example.com,李四
``` ```
黑名单中的评论者发布评论时,插件会跳过AI回复,并在日志中记录过滤原因 黑名单中的评论者发布评论时,插件会同时匹配显示名称和邮箱地址(不区分大小写),匹配成功则跳过AI回复。
+9
View File
@@ -5,19 +5,27 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
## 核心功能 ## 核心功能
- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 - **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
- **草稿模式** — AI回复先存为草稿,管理员审核后再发布 - **草稿模式** — AI回复先存为草稿,管理员审核后再发布
- **失败重试** — AI生成失败时自动重试,指数退避策略
- **批量操作** — 草稿模式下支持批量通过/拒绝/删除
- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 - **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
- **评论者黑名单** — 屏蔽指定评论者,不触发AI回复 - **评论者黑名单** — 屏蔽指定评论者,不触发AI回复
- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 - **手动触发** — 在评论管理页面对历史评论手动触发AI回复
- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像 - **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像
- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 - **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表
- **日志筛选搜索** — 按状态、情感筛选,关键词搜索
- **数据清理** — 自动清理超过指定天数的旧记录
- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 - **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
## 工作流程 ## 工作流程
``` ```
新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿 新评论 → 过滤检查 → 情感分析 → 构建Prompt → AI生成 → 安全审核 → 发布/草稿
↓ (失败)
重试 → ... → 最终失败
``` ```
1. **新评论到达** — Reconciler 监听到新评论创建事件 1. **新评论到达** — Reconciler 监听到新评论创建事件
@@ -27,6 +35,7 @@ AI回评(Comment AI Autopilot)是一个 Halo 博客系统的插件,能够
5. **AI生成** — 调用AI模型生成回复内容 5. **AI生成** — 调用AI模型生成回复内容
6. **安全审核** — 对生成内容进行安全审核 6. **安全审核** — 对生成内容进行安全审核
7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核 7. **发布/草稿** — 根据设置自动发布或存为草稿等待审核
8. **重试** — 如果AI生成失败,系统会自动重试(最多 maxRetryCount 次),每次重试间隔递增
## 前置要求 ## 前置要求
+11
View File
@@ -37,3 +37,14 @@ AI回复者的显示名称,默认为「小回」。修改后新回复将使用
::: warning ::: warning
如果不填写邮箱,AI回复者将使用 Halo 默认头像。 如果不填写邮箱,AI回复者将使用 Halo 默认头像。
::: :::
### 头像预览
在设置页面输入邮箱后,右侧会实时显示 Gravatar 头像预览,方便确认头像是否正确。
::: tip
如果预览头像不正确,请检查:
1. 邮箱是否拼写正确
2. 是否已在 [Gravatar](https://gravatar.com) 上为该邮箱设置头像
3. 头像更新可能有缓存延迟
:::
+12 -1
View File
@@ -9,7 +9,7 @@
| 自动回复 | 是否启用自动回复功能 | 开启 | | 自动回复 | 是否启用自动回复功能 | 开启 |
| 自动发布 | AI回复是否自动发布 | 开启 | | 自动发布 | AI回复是否自动发布 | 开启 |
| 最大重试次数 | AI生成失败时的最大重试次数 | 3 | | 最大重试次数 | AI生成失败时的最大重试次数 | 3 |
| 评论者黑名单 | 不触发AI回复的评论者显示名称,逗号分隔 | 空 | | 评论者黑名单 | 不触发AI回复的评论者显示名称或邮箱,逗号分隔 | 空 |
## AI角色设置 ## AI角色设置
@@ -53,3 +53,14 @@
| `{{article}}` | 文章内容 | | `{{article}}` | 文章内容 |
| `{{comment}}` | 评论内容 | | `{{comment}}` | 评论内容 |
| `{{conversation}}` | 对话上下文(多轮对话时) | | `{{conversation}}` | 对话上下文(多轮对话时) |
## 数据清理
| 配置项 | 说明 | 默认值 |
|--------|------|--------|
| 启用自动清理 | 是否自动清理过期的AI回复记录 | 开启 |
| 保留天数 | 超过此天数的记录将被自动清理 | 30 |
::: tip
你也可以在数据清理页面点击"立即清理"按钮手动触发清理操作。
:::
+7 -3
View File
@@ -15,11 +15,15 @@ hero:
features: features:
- title: 自动回复 - title: 自动回复
details: 监听新评论,自动调用AI生成回复,支持对话式上下文 details: 监听新评论,自动调用AI生成回复,支持对话式上下文和失败重试
- title: 多语言适配
details: 根据评论语言自动用对应语言回复,中文评论中文回复,英文评论英文回复
- title: 情感分析 - title: 情感分析
details: 分析评论情感倾向,根据正面/中性/负面调整回复语气 details: 分析评论情感倾向,根据正面/中性/负面调整回复语气
- title: 草稿模式 - title: 草稿模式
details: AI回复先存为草稿,管理员审核后再发布 details: AI回复先存为草稿,管理员审核后再发布,支持批量操作
- title: 灵活过滤 - title: 灵活过滤
details: 文章/页面级开关控制,评论者黑名单 details: 文章/页面级开关控制,评论者黑名单支持名称和邮箱匹配
- title: 数据管理
details: 仪表盘统计、日志筛选搜索、自动清理旧记录
--- ---
@@ -16,6 +16,7 @@ import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.PageRequestImpl;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -29,8 +30,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import static org.springframework.web.reactive.function.server.RouterFunctions.route; 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 ReactiveExtensionClient client;
private final AiReplyOrchestrator orchestrator; private final AiReplyOrchestrator orchestrator;
private final AiReplyCleanupService cleanupService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; 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.client = client;
this.orchestrator = orchestrator; this.orchestrator = orchestrator;
this.cleanupService = cleanupService;
this.objectMapper = new ObjectMapper(); this.objectMapper = new ObjectMapper();
} }
@@ -65,6 +70,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.POST("/replies/{name}/reject", this::rejectReply) .POST("/replies/{name}/reject", this::rejectReply)
.POST("/comments/{commentName}/trigger", this::triggerReply) .POST("/comments/{commentName}/trigger", this::triggerReply)
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply) .POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
.GET("/commenters", this::listCommenters)
.POST("/cleanup", this::triggerCleanup)
.build(); .build();
} }
@@ -76,10 +83,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listReplies(ServerRequest request) { private Mono<ServerResponse> listReplies(ServerRequest request) {
var page = Integer.parseInt(request.queryParam("page").orElse("1")); var page = Integer.parseInt(request.queryParam("page").orElse("1"));
var size = Integer.parseInt(request.queryParam("size").orElse("20")); var size = Integer.parseInt(request.queryParam("size").orElse("20"));
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp")); var statusFilter = request.queryParam("status").orElse("");
var pageable = PageRequestImpl.of(page, size, sort); 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<AiCommentReply> pageContent = fromIndex < total
? filtered.subList(fromIndex, toIndex) : List.of();
Map<String, Object> 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)); .flatMap(result -> ServerResponse.ok().bodyValue(result));
} }
@@ -495,4 +545,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
String time, String time,
boolean isAi boolean isAi
) {} ) {}
public record CommenterInfo(
String displayName,
String email
) {}
private Mono<ServerResponse> listCommenters(ServerRequest request) {
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.map(comments -> {
Set<String> seen = new HashSet<>();
List<CommenterInfo> 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<ServerResponse> 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()));
});
}
} }
@@ -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();
}
}
}
@@ -1,14 +1,15 @@
package top.nxxy335.commentaiautopilot.service; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.util.retry.Retry; import reactor.util.retry.Retry;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.ReactiveSettingFetcher;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import java.time.Duration; import java.time.Duration;
@@ -17,9 +18,10 @@ import java.util.concurrent.ConcurrentHashMap;
@Component @Component
@Slf4j @Slf4j
@RequiredArgsConstructor
public class AiReplyOrchestrator { public class AiReplyOrchestrator {
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
private final ContextExtractor contextExtractor; private final ContextExtractor contextExtractor;
private final PromptBuilder promptBuilder; private final PromptBuilder promptBuilder;
private final AiReplyService aiReplyService; private final AiReplyService aiReplyService;
@@ -28,12 +30,31 @@ public class AiReplyOrchestrator {
private final CommentReplyPublisher commentReplyPublisher; private final CommentReplyPublisher commentReplyPublisher;
private final FilterService filterService; private final FilterService filterService;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ReactiveSettingFetcher settingFetcher; private final ObjectMapper objectMapper;
// In-memory dedup: tracks which comment/reply is currently being processed // In-memory dedup: tracks which comment/reply is currently being processed
// Prevents duplicate replies when Reconciler fires multiple times // Prevents duplicate replies when Reconciler fires multiple times
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Boolean> 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. * Process a new comment or reply.
* *
@@ -151,6 +172,7 @@ public class AiReplyOrchestrator {
/** /**
* Generate AI reply, optionally review it, then publish. * Generate AI reply, optionally review it, then publish.
* Includes retry logic for empty AI replies and review failures.
*/ */
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context, private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
AiCommentReply replyRecord, String modelName) { AiCommentReply replyRecord, String modelName) {
@@ -159,7 +181,7 @@ public class AiReplyOrchestrator {
.flatMap(aiReply -> { .flatMap(aiReply -> {
if (aiReply.isBlank()) { if (aiReply.isBlank()) {
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId()); 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", log.info("[Orchestrator] AI generated reply for {}: {} chars",
@@ -172,7 +194,9 @@ public class AiReplyOrchestrator {
if ("FAIL".equals(reviewResult.status())) { if ("FAIL".equals(reviewResult.status())) {
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing", log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
context.commentId()); 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()); 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<Void> 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<Void> 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<AiCommentReply> 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. * Publish the reply and update the record to PASS + published=true.
*/ */
@@ -205,41 +296,104 @@ public class AiReplyOrchestrator {
} }
private Mono<String> getModelName() { private Mono<String> getModelName() {
return settingFetcher.getSettingValue("model") return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.map(node -> { .mapNotNull(cm -> {
var nameNode = node.get("modelName"); var data = cm.getData();
if (nameNode != null && !nameNode.asText().isBlank()) { if (data == null) return null;
return nameNode.asText(); 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 -> { .onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage()); log.debug("[Orchestrator] Failed to fetch model setting from ConfigMap: {}", e.getMessage());
return Mono.just(""); return Mono.empty();
}) })
.defaultIfEmpty(""); .defaultIfEmpty("");
} }
private Mono<Boolean> isAutoReplyEnabled() { private Mono<Boolean> isAutoReplyEnabled() {
return settingFetcher.getSettingValue("basic") return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true)) .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 -> { .onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage()); log.debug("[Orchestrator] Failed to fetch autoReply setting from ConfigMap: {}", e.getMessage());
return Mono.just(true); return Mono.empty();
}) })
.defaultIfEmpty(true); .defaultIfEmpty(true);
} }
private Mono<Boolean> isAutoPublishEnabled() { private Mono<Boolean> isAutoPublishEnabled() {
return settingFetcher.getSettingValue("basic") return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true)) .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 -> { .onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage()); log.debug("[Orchestrator] Failed to fetch autoPublish setting from ConfigMap: {}", e.getMessage());
return Mono.just(true); return Mono.empty();
}) })
.defaultIfEmpty(true); .defaultIfEmpty(true);
} }
private Mono<Integer> 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<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) { private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
AiCommentReply record = new AiCommentReply(); AiCommentReply record = new AiCommentReply();
record.setMetadata(new Metadata()); record.setMetadata(new Metadata());
@@ -70,8 +70,9 @@ public class FilterService {
? node.get("blockedCommenters").asText("") : ""; ? node.get("blockedCommenters").asText("") : "";
List<String> blockedCommenters = parseList(blockedCommentersStr); List<String> blockedCommenters = parseList(blockedCommentersStr);
String commenterName = getCommenterDisplayName(comment); String commenterName = getCommenterDisplayName(comment);
if (isInList(commenterName, blockedCommenters)) { String commenterEmail = getCommenterEmail(comment);
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName); if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
log.info("[Filter] Commenter '{}' (email: '{}') is in blocked list, skipping", commenterName, commenterEmail);
return true; return true;
} }
return false; return false;
@@ -130,6 +131,16 @@ public class FilterService {
return displayName != null ? displayName : ""; 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<String> parseList(String str) { private List<String> parseList(String str) {
if (str == null || str.isBlank()) return Collections.emptyList(); if (str == null || str.isBlank()) return Collections.emptyList();
return Arrays.stream(str.split(",")) return Arrays.stream(str.split(","))
@@ -1,17 +1,25 @@
package top.nxxy335.commentaiautopilot.service; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.plugin.ReactiveSettingFetcher; import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
@Component @Component
@Slf4j @Slf4j
@RequiredArgsConstructor
public class PromptBuilder { 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 = """ private static final String SAFETY_PROMPT = """
【安全规范】 【安全规范】
@@ -76,13 +84,22 @@ public class PromptBuilder {
} }
private Mono<String> getPromptTemplate() { private Mono<String> getPromptTemplate() {
return settingFetcher.getSettingValue("prompt") return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.map(node -> { .mapNotNull(cm -> {
var templateNode = node.get("customPromptTemplate"); var data = cm.getData();
if (templateNode != null && !templateNode.asText().isBlank()) { if (data == null) return null;
return templateNode.asText(); 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 -> { .onErrorResume(e -> {
log.debug("Failed to fetch prompt template setting: {}", e.getMessage()); log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
@@ -92,13 +109,22 @@ public class PromptBuilder {
} }
private Mono<String> getPersonaPrompt() { private Mono<String> getPersonaPrompt() {
return settingFetcher.getSettingValue("persona") return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.map(node -> { .mapNotNull(cm -> {
var promptNode = node.get("personaPrompt"); var data = cm.getData();
if (promptNode != null && !promptNode.asText().isBlank()) { if (data == null) return null;
return promptNode.asText(); 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 -> { .onErrorResume(e -> {
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage()); log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
+15 -1
View File
@@ -24,7 +24,7 @@ spec:
- $formkit: textarea - $formkit: textarea
name: blockedCommenters name: blockedCommenters
label: 评论者黑名单 label: 评论者黑名单
help: 输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复 help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复
value: "" value: ""
- group: persona - group: persona
label: AI角色设置 label: AI角色设置
@@ -57,4 +57,18 @@ spec:
name: customPromptTemplate name: customPromptTemplate
label: 自定义Prompt模板 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}}" 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
+1 -1
View File
@@ -24,4 +24,4 @@ spec:
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "0.0.1-w5s2t7" version: "0.0.1-w5s2t7"
pluginDependencies: pluginDependencies:
ai-foundation?: "*" ai-foundation: "*"
+58 -1
View File
@@ -38,6 +38,41 @@
</button> </button>
</div> </div>
<!-- Filter Bar -->
<div class="mx-4 mt-2 flex items-center gap-3">
<select
v-model="filterStatus"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">全部状态</option>
<option value="PASS">通过</option>
<option value="FAIL">失败</option>
<option value="PENDING">待审核</option>
<option value="REJECTED">已拒绝</option>
</select>
<select
v-model="filterSentiment"
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">全部情感</option>
<option value="POSITIVE">正面</option>
<option value="NEUTRAL">中性</option>
<option value="NEGATIVE">负面</option>
</select>
<input
v-model="filterKeyword"
type="text"
placeholder="搜索回复内容..."
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
class="text-xs text-gray-500 hover:text-gray-700"
@click="resetFilters"
>
重置
</button>
</div>
<div class="m-4"> <div class="m-4">
<VLoading v-if="loading" /> <VLoading v-if="loading" />
@@ -312,6 +347,11 @@ const totalPages = ref(0)
const selectedNames = ref<Set<string>>(new Set()) const selectedNames = ref<Set<string>>(new Set())
const selectAll = ref(false) const selectAll = ref(false)
// Filter state
const filterStatus = ref("")
const filterSentiment = ref("")
const filterKeyword = ref("")
const toggleSelect = (name: string) => { const toggleSelect = (name: string) => {
if (selectedNames.value.has(name)) { if (selectedNames.value.has(name)) {
selectedNames.value.delete(name) selectedNames.value.delete(name)
@@ -340,9 +380,13 @@ const conversationMessages = ref<ConversationMessage[]>([])
const fetchReplies = async () => { const fetchReplies = async () => {
loading.value = true loading.value = true
try { try {
const params: Record<string, string | number> = { 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( const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies", "/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/replies",
{ params: { page: page.value, size: size.value } }, { params },
) )
replies.value = data.items || [] replies.value = data.items || []
total.value = data.total || 0 total.value = data.total || 0
@@ -561,6 +605,19 @@ const renderContent = (content: string) => {
.replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ") .replace(/<a /gi, "<a target='_blank' rel='noopener noreferrer' ")
} }
const resetFilters = () => {
filterStatus.value = ""
filterSentiment.value = ""
filterKeyword.value = ""
page.value = 1
fetchReplies()
}
watch([filterStatus, filterSentiment, filterKeyword], () => {
page.value = 1
fetchReplies()
})
watch(page, () => { watch(page, () => {
selectedNames.value.clear() selectedNames.value.clear()
selectAll.value = false selectAll.value = false
+242 -11
View File
@@ -62,8 +62,13 @@
/> />
</div> </div>
<div> <div>
<label class="font-medium">评论者黑名单</label> <div class="flex items-center justify-between">
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称多个用逗号分隔这些评论者的评论不会触发AI回复</div> <label class="font-medium">评论者黑名单</label>
<VButton size="sm" @click="openCommenterDialog">
添加评论者
</VButton>
</div>
<div class="mt-1 text-sm text-gray-500">输入评论者显示名称或邮箱多个用逗号分隔这些评论者的评论不会触发AI回复</div>
<textarea <textarea
v-model="settings.basic.blockedCommenters" v-model="settings.basic.blockedCommenters"
rows="3" rows="3"
@@ -87,12 +92,30 @@
<div> <div>
<label class="font-medium">AI角色邮箱</label> <label class="font-medium">AI角色邮箱</label>
<div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像留空则使用默认头像</div> <div class="mt-1 text-sm text-gray-500">用于Gravatar头像服务展示头像留空则使用默认头像</div>
<input <div class="mt-1 flex items-start gap-4">
type="email" <input
v-model="settings.persona.personaEmail" type="email"
class="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" v-model="settings.persona.personaEmail"
placeholder="ai@example.com" class="block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/> placeholder="ai@example.com"
/>
<div class="flex-shrink-0">
<div
v-if="avatarUrl"
class="h-12 w-12 overflow-hidden rounded-full border border-gray-200"
>
<img :src="avatarUrl" alt="头像预览" class="h-full w-full object-cover" />
</div>
<div
v-else
class="flex h-12 w-12 items-center justify-center rounded-full border border-gray-200 bg-gray-100"
>
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
</div>
</div> </div>
<div> <div>
<label class="font-medium">AI角色人格提示词</label> <label class="font-medium">AI角色人格提示词</label>
@@ -138,6 +161,39 @@
</div> </div>
</div> </div>
<!-- Cleanup Settings -->
<div v-if="activeTab === 'cleanup' && !loading" class="space-y-6">
<div class="flex items-center justify-between">
<div>
<div class="font-medium">启用自动清理</div>
<div class="text-sm text-gray-500">启用后将自动清理过期的AI回复记录</div>
</div>
<label class="relative inline-flex cursor-pointer items-center">
<input type="checkbox" v-model="settings.cleanup.cleanupEnabled" class="peer sr-only" />
<div class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
</label>
</div>
<div>
<label class="font-medium">保留天数</label>
<div class="mt-1 text-sm text-gray-500">超过保留天数的AI回复记录将被自动清理</div>
<input
type="number"
v-model.number="settings.cleanup.retentionDays"
min="1"
max="365"
class="mt-1 block w-full max-w-xs rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<VButton @click="performCleanup" :disabled="cleanupLoading">
{{ cleanupLoading ? '清理中...' : '立即清理' }}
</VButton>
<div v-if="cleanupResult !== null" class="mt-2 text-sm text-green-600">
清理完成共删除 {{ cleanupResult }} 条记录
</div>
</div>
</div>
<!-- Save Button --> <!-- Save Button -->
<div v-if="!loading" class="mt-6 flex justify-end"> <div v-if="!loading" class="mt-6 flex justify-end">
<VButton type="primary" @click="saveSettings" :disabled="saving"> <VButton type="primary" @click="saveSettings" :disabled="saving">
@@ -147,11 +203,61 @@
</div> </div>
</VCard> </VCard>
</div> </div>
<!-- Commenter Selection Dialog -->
<div
v-if="showCommenterDialog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showCommenterDialog = false"
>
<div class="w-full max-w-lg rounded-lg bg-white shadow-xl">
<div class="border-b px-6 py-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium">选择评论者</h3>
<button
class="text-gray-400 hover:text-gray-600"
@click="showCommenterDialog = false"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input
v-model="commenterSearch"
type="text"
class="mt-3 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="搜索评论者名称或邮箱..."
/>
</div>
<div class="max-h-80 overflow-y-auto px-6 py-3">
<VLoading v-if="commenterLoading" />
<div v-else-if="filteredCommenters.length === 0" class="py-8 text-center text-sm text-gray-500">
暂无评论者数据
</div>
<div v-else class="space-y-2">
<div
v-for="commenter in filteredCommenters"
:key="commenter.name + commenter.email"
class="flex items-center justify-between rounded-md border border-gray-100 px-4 py-3 hover:bg-gray-50"
>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-sm">{{ commenter.name }}</div>
<div v-if="commenter.email" class="truncate text-xs text-gray-500">{{ commenter.email }}</div>
</div>
<VButton size="sm" @click="addCommenter(commenter)">
添加
</VButton>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from "vue" import { ref, reactive, computed, onMounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client" import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components" import { VPageHeader, VButton, VCard, VLoading, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components" import { IconPlug } from "@halo-dev/components"
@@ -161,12 +267,27 @@ const tabs = [
{ key: "persona", label: "AI角色设置" }, { key: "persona", label: "AI角色设置" },
{ key: "model", label: "模型设置" }, { key: "model", label: "模型设置" },
{ key: "prompt", label: "Prompt设置" }, { key: "prompt", label: "Prompt设置" },
{ key: "cleanup", label: "数据清理" },
] ]
const activeTab = ref("basic") const activeTab = ref("basic")
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
// Commenter dialog state
const showCommenterDialog = ref(false)
const commenterList = ref<{ name: string; email: string }[]>([])
const commenterSearch = ref("")
const commenterLoading = ref(false)
// Avatar preview state
const avatarUrl = ref("")
let avatarDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Cleanup state
const cleanupLoading = ref(false)
const cleanupResult = ref<number | null>(null)
const settings = reactive({ const settings = reactive({
basic: { basic: {
autoReply: true, autoReply: true,
@@ -185,10 +306,114 @@ const settings = reactive({
prompt: { prompt: {
customPromptTemplate: "", customPromptTemplate: "",
}, },
cleanup: {
cleanupEnabled: true,
retentionDays: 30,
},
}) })
const configMapName = "comment-ai-autopilot-configmap" const configMapName = "comment-ai-autopilot-configmap"
// --- Task 4: Commenter blacklist enhancement ---
const openCommenterDialog = async () => {
showCommenterDialog.value = true
commenterSearch.value = ""
commenterLoading.value = true
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/commenters",
)
commenterList.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) {
console.error("Failed to fetch commenters", e)
Toast.error("获取评论者列表失败")
commenterList.value = []
} finally {
commenterLoading.value = false
}
}
const filteredCommenters = computed(() => {
const keyword = commenterSearch.value.trim().toLowerCase()
if (!keyword) return commenterList.value
return commenterList.value.filter(
(c) =>
c.name.toLowerCase().includes(keyword) ||
(c.email && c.email.toLowerCase().includes(keyword)),
)
})
const addCommenter = (commenter: { name: string; email: string }) => {
const value = commenter.email || commenter.name
if (!value) return
const current = settings.basic.blockedCommenters
.split(",")
.map((s) => s.trim())
.filter(Boolean)
if (current.includes(value)) {
Toast.info("该评论者已在黑名单中")
return
}
current.push(value)
settings.basic.blockedCommenters = current.join(",")
Toast.success("已添加到黑名单")
}
// --- Task 7: Avatar preview ---
const computeGravatarHash = async (email: string): Promise<string> => {
const normalized = email.trim().toLowerCase()
const encoder = new TextEncoder()
const data = encoder.encode(normalized)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}
watch(
() => settings.persona.personaEmail,
(newEmail) => {
if (avatarDebounceTimer) {
clearTimeout(avatarDebounceTimer)
}
if (!newEmail || !newEmail.trim()) {
avatarUrl.value = ""
return
}
avatarDebounceTimer = setTimeout(async () => {
try {
const hash = await computeGravatarHash(newEmail)
avatarUrl.value = `https://cn.cravatar.com/avatar/${hash}`
} catch (e) {
console.error("Failed to compute Gravatar hash", e)
avatarUrl.value = ""
}
}, 500)
},
)
// --- Task 10: Cleanup ---
const performCleanup = async () => {
cleanupLoading.value = true
cleanupResult.value = null
try {
const { data } = await axiosInstance.post(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/cleanup",
)
cleanupResult.value = data.deletedCount ?? data ?? 0
Toast.success(`清理完成,共删除 ${cleanupResult.value} 条记录`)
} catch (e) {
console.error("Failed to perform cleanup", e)
Toast.error("清理失败")
} finally {
cleanupLoading.value = false
}
}
// --- Settings fetch & save ---
const fetchSettings = async () => { const fetchSettings = async () => {
loading.value = true loading.value = true
try { try {
@@ -214,7 +439,10 @@ const fetchSettings = async () => {
if (d.prompt) { if (d.prompt) {
settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || "" settings.prompt.customPromptTemplate = d.prompt.customPromptTemplate || ""
} }
if (d.cleanup) {
settings.cleanup.cleanupEnabled = d.cleanup.cleanupEnabled !== false
settings.cleanup.retentionDays = d.cleanup.retentionDays || 30
}
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch settings", e) console.error("Failed to fetch settings", e)
@@ -251,7 +479,10 @@ const saveSettings = async () => {
prompt: { prompt: {
customPromptTemplate: settings.prompt.customPromptTemplate, customPromptTemplate: settings.prompt.customPromptTemplate,
}, },
cleanup: {
cleanupEnabled: settings.cleanup.cleanupEnabled,
retentionDays: settings.cleanup.retentionDays,
},
} }
await axiosInstance.put( await axiosInstance.put(