diff --git a/README.md b/README.md
index 35becc3..3cc576a 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,28 @@
# AI回评 / Comment AI Autopilot
-基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复。
+基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。
## 功能特性
-- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文
+- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色
+- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文
- **多语言适配** — 根据评论语言自动用对应语言回复
- **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气
-- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持批量操作
-- **失败重试** — AI生成失败时自动重试,指数退避策略
-- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭
-- **评论者黑名单** — 支持按名称和邮箱屏蔽指定评论者,可从评论列表选择
-- **手动触发** — 在评论管理页面对历史评论手动触发AI回复
-- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,设置页面实时预览头像
-- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝
-- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表
+- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作
+- **失败重试** — AI 生成失败时自动重试,指数退避策略
+- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话
+- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度
+- **文章/页面级开关** — 在文章编辑器中直接控制是否启用 AI 回复,文章默认开启,页面默认关闭
+- **评论者黑名单** — 支持按名称、邮箱和正则表达式屏蔽指定评论者,可从评论列表选择
+- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复
+- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝
+- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等)
+- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合
+- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换
+- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性
- **日志筛选** — 按状态、情感筛选,关键词搜索
- **数据清理** — 自动清理超过指定天数的旧记录
-- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力
+- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力
## 前置要求
@@ -26,7 +31,7 @@
## 安装
-1. 前往 [Releases](https://github.com/暖心向阳335/comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
+1. 前往 [Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件
2. 登录 Halo 管理后台
3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮
4. 选择下载的 `.jar` 文件上传
@@ -36,7 +41,7 @@
```bash
# 克隆仓库
-git clone https://github.com/暖心向阳335/comment-ai-autopilot.git
+git clone https://github.com/sunny-335/plugin-comment-ai-autopilot.git
cd plugin-comment-ai-autopilot
# 构建
@@ -59,7 +64,7 @@ pnpm dev
## 文档
-完整文档请访问 [AI回评文档站](https://暖心向阳335.github.io/comment-ai-autopilot/)
+完整文档请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot)
## 许可证
diff --git a/build.gradle b/build.gradle
index 6010441..9bd62d3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -5,7 +5,7 @@ plugins {
}
group 'top.nxxy335.commentaiautopilot'
-version '0.0.1-w5s2t7'
+version '0.0.1-t5w8r3'
repositories {
mavenCentral()
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 38718ab..a49932f 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -4,6 +4,7 @@ export default defineConfig({
title: "AI回评",
description: "Halo AI回评插件文档",
lang: "zh-CN",
+ base: "/comment-ai-autopilot/",
themeConfig: {
nav: [
{ text: "指南", link: "/guide/introduction" },
diff --git a/docs/guide/prompt.md b/docs/guide/prompt.md
index 4849b4b..5516821 100644
--- a/docs/guide/prompt.md
+++ b/docs/guide/prompt.md
@@ -15,6 +15,9 @@ Prompt模板控制AI生成回复时的完整提示词结构。
- 自然对话,不要写小作文
- 只有评论涉及具体内容时才针对性回应
+文章标题:{{post_title}}
+发布日期:{{post_date}}
+评论数:{{comment_count}}
文章(仅供理解上下文,不要复述):
{{article}}
@@ -29,8 +32,11 @@ Prompt模板控制AI生成回复时的完整提示词结构。
| `{{persona_prompt}}` | AI角色人格提示词 | 始终注入 |
| `{{safety_prompt}}` | 安全规范提示词 | 始终注入 |
| `{{sentiment_prompt}}` | 情感语气提示词 | 情感分析后自动注入,不在模板中显式使用 |
-| `{{article}}` | 文章/页面内容 | 始终注入 |
-| `{{comment}}` | 评论内容 | 始终注入 |
+| `{{post_title}}` | 文章标题 | 始终注入 |
+| `{{post_date}}` | 文章发布日期(如 2024-01-15) | 始终注入 |
+| `{{comment_count}}` | 该文章的评论数 | 始终注入 |
+| `{{article}}` | 文章/页面内容(含标题) | 始终注入 |
+| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 |
| `{{conversation}}` | 对话上下文 | 多轮对话时注入 |
## 情感提示
@@ -56,6 +62,21 @@ Prompt模板控制AI生成回复时的完整提示词结构。
1. 保留 `{{persona_prompt}}` 和 `{{safety_prompt}}` 变量
2. 保留 `{{article}}` 和 `{{comment}}` 变量
-3. 在变量之间添加清晰的分隔和指令
-4. 避免让AI复述文章内容
-5. 控制回复长度和风格
+3. 利用 `{{post_title}}`、`{{post_date}}`、`{{comment_count}}` 提供更丰富的上下文
+4. 在变量之间添加清晰的分隔和指令
+5. 避免让AI复述文章内容
+6. 控制回复长度和风格
+
+## 变量使用示例
+
+### 根据评论数调整回复风格
+
+```
+{{comment_count}}条评论说明这篇文章{{#if comment_count > 10}}很受欢迎{{/if}}。
+```
+
+### 利用发布日期
+
+```
+这篇文章发布于{{post_date}},回复时请考虑时效性。
+```
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java
index 57e9c12..d8de177 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java
@@ -1,12 +1,17 @@
package top.nxxy335.commentaiautopilot;
import org.springframework.stereotype.Component;
+import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.IndexSpecs;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeManager;
+import run.halo.app.extension.Metadata;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
+import top.nxxy335.commentaiautopilot.extension.AiPersona;
+import lombok.extern.slf4j.Slf4j;
+import reactor.core.publisher.Mono;
/**
*
Plugin main class to manage the lifecycle of the plugin.
@@ -16,14 +21,17 @@ import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
* @author 暖心向阳335
* @since 1.0.0
*/
+@Slf4j
@Component
public class CommentAiAutopilotPlugin extends BasePlugin {
private final SchemeManager schemeManager;
+ private final ReactiveExtensionClient client;
- public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
+ public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager, ReactiveExtensionClient client) {
super(pluginContext);
this.schemeManager = schemeManager;
+ this.client = client;
}
@Override
@@ -36,10 +44,36 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
indexSpecs.add(IndexSpecs.single("spec.status", String.class)
.indexFunc(ext -> ext.getSpec().getStatus()));
});
+ schemeManager.register(AiPersona.class);
+
+ // 初始化默认AI角色"小回"
+ initDefaultPersona();
+ }
+
+ private void initDefaultPersona() {
+ client.fetch(AiPersona.class, "default-ai-persona")
+ .switchIfEmpty(Mono.defer(() -> {
+ log.info("初始化默认AI角色:小回");
+ AiPersona persona = new AiPersona();
+ persona.setMetadata(new Metadata());
+ persona.getMetadata().setName("default-ai-persona");
+ AiPersona.AiPersonaSpec spec = new AiPersona.AiPersonaSpec();
+ spec.setDisplayName("小回");
+ spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。");
+ spec.setEmail("");
+ spec.setIsDefault(true);
+ persona.setSpec(spec);
+ return client.create(persona);
+ }))
+ .subscribe(
+ created -> log.info("默认AI角色已就绪"),
+ err -> log.warn("初始化默认AI角色失败: {}", err.getMessage())
+ );
}
@Override
public void stop() {
schemeManager.unregister(Scheme.buildFromType(AiCommentReply.class));
+ schemeManager.unregister(Scheme.buildFromType(AiPersona.class));
}
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java
index 8b8245e..2d4f13b 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java
@@ -1,6 +1,7 @@
package top.nxxy335.commentaiautopilot.endpoint;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -8,6 +9,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Comment;
+import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ConfigMap;
@@ -16,6 +18,8 @@ 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.extension.AiPersona;
+import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
@@ -44,14 +48,16 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final AiReplyOrchestrator orchestrator;
private final AiReplyCleanupService cleanupService;
+ private final ObjectProvider aiFoundationClientProvider;
private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
- public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService) {
+ public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider aiFoundationClientProvider) {
this.client = client;
this.orchestrator = orchestrator;
this.cleanupService = cleanupService;
+ this.aiFoundationClientProvider = aiFoundationClientProvider;
this.objectMapper = new ObjectMapper();
}
@@ -72,6 +78,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
.GET("/commenters", this::listCommenters)
.POST("/cleanup", this::triggerCleanup)
+ .GET("/health", this::health)
+ .GET("/personas", this::listPersonas)
+ .GET("/personas/{name}", this::getPersonaByName)
+ .POST("/personas", this::createPersona)
+ .PUT("/personas/{name}", this::updatePersona)
+ .DELETE("/personas/{name}", this::deletePersona)
.build();
}
@@ -142,9 +154,39 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
private Mono getStats(ServerRequest request) {
+ String range = request.queryParam("range").orElse("7");
+
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
- .map(replies -> {
+ .map(allReplies -> {
+ // 根据 range 计算截止时间
+ ZoneId zoneId = ZoneId.systemDefault();
+ LocalDate today = LocalDate.now(zoneId);
+ Instant cutoffInstant;
+ int trendDays;
+
+ if ("all".equals(range)) {
+ cutoffInstant = null; // 不做时间过滤
+ trendDays = 30; // "all" 时趋势也展示最近30天
+ } else {
+ int days = Integer.parseInt(range);
+ cutoffInstant = today.minusDays(days).atStartOfDay(zoneId).toInstant();
+ trendDays = days;
+ }
+
+ // 根据 range 过滤记录
+ List replies;
+ if (cutoffInstant != null) {
+ replies = allReplies.stream()
+ .filter(r -> {
+ Instant ts = r.getMetadata().getCreationTimestamp();
+ return ts != null && !ts.isBefore(cutoffInstant);
+ })
+ .toList();
+ } else {
+ replies = allReplies;
+ }
+
long total = replies.size();
long passCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
@@ -174,11 +216,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
}
- ZoneId zoneId = ZoneId.systemDefault();
+ // 计算 dailyTrend
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
- LocalDate today = LocalDate.now(zoneId);
Map dailyMap = new HashMap<>();
- for (int i = 0; i < 7; i++) {
+ for (int i = 0; i < trendDays; i++) {
dailyMap.put(today.minusDays(i), 0L);
}
for (var r : replies) {
@@ -194,7 +235,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
}
List dailyTrend = new ArrayList<>();
- for (int i = 0; i < 7; i++) {
+ for (int i = 0; i < trendDays; i++) {
LocalDate date = today.minusDays(i);
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
}
@@ -212,29 +253,35 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
private Mono getPersona(ServerRequest request) {
- return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
- .mapNotNull(cm -> {
- var data = cm.getData();
- if (data == null) return new PersonaResponse("小回", "", "");
- String personaJson = data.get("persona");
- if (personaJson == null || personaJson.isBlank()) return new PersonaResponse("小回", "", "");
- try {
- JsonNode node = objectMapper.readTree(personaJson);
- String name = node.has("personaName") ? node.get("personaName").asText("小回") : "小回";
- String prompt = node.has("personaPrompt") ? node.get("personaPrompt").asText("") : "";
- String email = node.has("personaEmail") ? node.get("personaEmail").asText("") : "";
- return new PersonaResponse(name, prompt, email);
- } catch (Exception e) {
- log.warn("Failed to parse persona config: {}", e.getMessage());
- return new PersonaResponse("小回", "", "");
+ return client.list(AiPersona.class,
+ persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
+ null)
+ .next()
+ .flatMap(persona -> {
+ String email = persona.getSpec().getEmail();
+ String avatarUrl = "";
+ if (email != null && !email.isBlank()) {
+ try {
+ var digest = java.security.MessageDigest.getInstance("SHA-256");
+ var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ var hexString = new StringBuilder();
+ for (byte b : hashBytes) {
+ hexString.append(String.format("%02x", b));
+ }
+ avatarUrl = "https://cn.cravatar.com/avatar/" + hexString;
+ } catch (Exception ignored) {}
}
+ return ServerResponse.ok().bodyValue(Map.of(
+ "name", persona.getSpec().getDisplayName(),
+ "prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
+ "avatar", avatarUrl
+ ));
})
- .defaultIfEmpty(new PersonaResponse("小回", "", ""))
- .onErrorResume(e -> {
- log.warn("Failed to fetch persona settings: {}", e.getMessage());
- return Mono.just(new PersonaResponse("小回", "", ""));
- })
- .flatMap(persona -> ServerResponse.ok().bodyValue(persona));
+ .switchIfEmpty(ServerResponse.ok().bodyValue(Map.of(
+ "name", "小回",
+ "prompt", "",
+ "avatar", ""
+ )));
}
public record DailyCount(String date, long count) {}
@@ -489,9 +536,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
}
- // Trigger the orchestrator
- return orchestrator.processComment(commentName, null, false)
- .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")));
+ // Read persona name from post annotations
+ return getPersonaNameFromComment(commentName)
+ .flatMap(personaName ->
+ orchestrator.processComment(commentName, null, false, personaName)
+ .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
+ );
});
}
@@ -514,15 +564,44 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
}
- return orchestrator.processComment(commentName, replyName, true)
- .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")));
+ return getPersonaNameFromComment(commentName)
+ .flatMap(personaName ->
+ orchestrator.processComment(commentName, replyName, true, personaName)
+ .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
+ );
});
})
.switchIfEmpty(ServerResponse.notFound().build());
}
+ private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
+
+ private Mono getPersonaNameFromComment(String commentName) {
+ return client.fetch(Comment.class, commentName)
+ .flatMap(comment -> {
+ var subjectRef = comment.getSpec().getSubjectRef();
+ if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
+ return Mono.justOrEmpty(null);
+ }
+ String postName = subjectRef.getName();
+ return client.fetch(Post.class, postName)
+ .mapNotNull(post -> {
+ var annotations = post.getMetadata().getAnnotations();
+ if (annotations != null) {
+ String persona = annotations.get(AI_PERSONA_ANNOTATION);
+ if (persona != null && !persona.isBlank()) {
+ return persona;
+ }
+ }
+ return null;
+ });
+ })
+ .defaultIfEmpty("");
+ }
+
private Mono findReplyForRecord(AiCommentReply record) {
// Find the Reply that belongs to the same comment and was created by AI
+ // If the record has a quoteReply, match by that too for precision
return client.list(Reply.class,
reply -> {
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
@@ -531,7 +610,14 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var owner = reply.getSpec().getOwner();
if (owner == null) return false;
var annotations = owner.getAnnotations();
- return annotations != null && "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
+ if (annotations == null || !"true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"))) {
+ return false;
+ }
+ // If record has a quoteReply, also match by quoteReply for precision
+ if (record.getSpec().getReplyTo() != null && !record.getSpec().getReplyTo().isBlank()) {
+ return record.getSpec().getReplyTo().equals(reply.getSpec().getQuoteReply());
+ }
+ return true;
},
null)
.next()
@@ -586,4 +672,97 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.bodyValue(Map.of("message", "清理失败: " + e.getMessage()));
});
}
+
+ private Mono health(ServerRequest request) {
+ AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable();
+ boolean aiFoundationInstalled = aiClient != null;
+
+ if (!aiFoundationInstalled) {
+ return ServerResponse.ok().bodyValue(
+ new HealthResponse(false, false, false, "", "unhealthy"));
+ }
+
+ // AI Foundation is installed, check if it's enabled and model is available
+ return aiClient.chat("ping", null)
+ .map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy"))
+ .onErrorResume(e -> {
+ log.debug("Health check: AI Foundation call failed: {}", e.getMessage());
+ return Mono.just(new HealthResponse(true, true, false, "", "degraded"));
+ })
+ .flatMap(health -> ServerResponse.ok().bodyValue(health));
+ }
+
+ public record HealthResponse(
+ boolean aiFoundationInstalled,
+ boolean aiFoundationEnabled,
+ boolean modelAvailable,
+ String modelName,
+ String status
+ ) {}
+
+ private Mono listPersonas(ServerRequest request) {
+ return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
+ .collectList()
+ .flatMap(personas -> ServerResponse.ok().bodyValue(personas));
+ }
+
+ private Mono getPersonaByName(ServerRequest request) {
+ var name = request.pathVariable("name");
+ return client.fetch(AiPersona.class, name)
+ .flatMap(persona -> ServerResponse.ok().bodyValue(persona))
+ .switchIfEmpty(ServerResponse.notFound().build());
+ }
+
+ private Mono createPersona(ServerRequest request) {
+ return request.bodyToMono(AiPersona.class)
+ .flatMap(persona -> {
+ if (persona.getMetadata() == null) {
+ persona.setMetadata(new run.halo.app.extension.Metadata());
+ }
+ if (persona.getMetadata().getName() == null || persona.getMetadata().getName().isBlank()) {
+ persona.getMetadata().setName("ai-persona-" + java.util.UUID.randomUUID().toString().substring(0, 8));
+ }
+ return client.create(persona)
+ .flatMap(created -> ServerResponse.ok().bodyValue(created))
+ .onErrorResume(e -> {
+ log.warn("Failed to create persona: {}", e.getMessage());
+ return ServerResponse.badRequest()
+ .bodyValue(Map.of("message", "创建角色失败: " + e.getMessage()));
+ });
+ })
+ .switchIfEmpty(ServerResponse.badRequest()
+ .bodyValue(Map.of("message", "请求体不能为空")));
+ }
+
+ private Mono updatePersona(ServerRequest request) {
+ var name = request.pathVariable("name");
+ return request.bodyToMono(AiPersona.class)
+ .flatMap(updatedPersona -> client.fetch(AiPersona.class, name)
+ .flatMap(existing -> {
+ existing.setSpec(updatedPersona.getSpec());
+ return client.update(existing);
+ })
+ .flatMap(saved -> ServerResponse.ok().bodyValue(saved))
+ .onErrorResume(e -> {
+ log.warn("Failed to update persona {}: {}", name, e.getMessage());
+ return ServerResponse.badRequest()
+ .bodyValue(Map.of("message", "更新角色失败: " + e.getMessage()));
+ })
+ )
+ .switchIfEmpty(ServerResponse.notFound().build());
+ }
+
+ private Mono deletePersona(ServerRequest request) {
+ var name = request.pathVariable("name");
+ return client.fetch(AiPersona.class, name)
+ .flatMap(persona -> {
+ if (persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault())) {
+ return ServerResponse.badRequest()
+ .bodyValue(Map.of("message", "默认角色不可删除,请先将其他角色设为默认"));
+ }
+ return client.delete(persona)
+ .then(ServerResponse.ok().bodyValue(Map.of("message", "deleted")));
+ })
+ .switchIfEmpty(ServerResponse.notFound().build());
+ }
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java
index c04c1e6..2012362 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java
@@ -56,5 +56,8 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
private String sentiment;
+
+ @Schema(description = "使用的AI角色名称")
+ private String personaName;
}
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java
new file mode 100644
index 0000000..7d2ee31
--- /dev/null
+++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java
@@ -0,0 +1,41 @@
+package top.nxxy335.commentaiautopilot.extension;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import run.halo.app.extension.AbstractExtension;
+import run.halo.app.extension.GVK;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@GVK(
+ group = "comment-ai-autopilot.nxxy335.top",
+ version = "v1alpha1",
+ kind = "AiPersona",
+ plural = "aipersonas",
+ singular = "aipersona"
+)
+public class AiPersona extends AbstractExtension {
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ private AiPersonaSpec spec;
+
+ @Data
+ @Schema(name = "AiPersonaSpec")
+ public static class AiPersonaSpec {
+
+ @Schema(description = "角色昵称")
+ private String displayName;
+
+ @Schema(description = "人格提示词")
+ private String prompt;
+
+ @Schema(description = "邮箱(用于Gravatar头像)")
+ private String email;
+
+ @Schema(description = "是否为默认角色")
+ @JsonProperty("isDefault")
+ private Boolean isDefault;
+ }
+}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java
new file mode 100644
index 0000000..3cde27a
--- /dev/null
+++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java
@@ -0,0 +1,31 @@
+package top.nxxy335.commentaiautopilot.listener;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import run.halo.app.extension.ReactiveExtensionClient;
+import run.halo.app.extension.controller.Controller;
+import run.halo.app.extension.controller.ControllerBuilder;
+import run.halo.app.extension.controller.Reconciler;
+import top.nxxy335.commentaiautopilot.extension.AiPersona;
+
+@Component
+@Slf4j
+@RequiredArgsConstructor
+public class AiPersonaReconciler implements Reconciler {
+
+ private final ReactiveExtensionClient client;
+
+ @Override
+ public Result reconcile(Request request) {
+ return new Result(false, null);
+ }
+
+ @Override
+ public Controller setupWith(ControllerBuilder builder) {
+ return builder
+ .extension(new AiPersona())
+ .syncAllOnStart(false)
+ .build();
+ }
+}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java
index 2543f23..f8ef3a0 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.content.Comment;
+import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
@@ -29,6 +30,7 @@ public class CommentReconciler implements Reconciler {
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
+ private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
// Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now();
@@ -88,10 +90,13 @@ public class CommentReconciler implements Reconciler {
markProcessed(comment);
client.update(comment);
+ // Read persona name from the post's annotations
+ String personaName = getPersonaNameFromComment(comment);
+
// Top-level comment → always trigger AI reply
- log.info("[CommentReconciler] New top-level comment detected: {}", name);
+ log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
asyncStarted.set(true);
- orchestrator.processComment(name, null, false)
+ orchestrator.processComment(name, null, false, personaName)
.subscribeOn(Schedulers.boundedElastic())
.doFinally(signal -> {
processingLocks.remove(name);
@@ -132,6 +137,29 @@ public class CommentReconciler implements Reconciler {
return false;
}
+ /**
+ * Read persona name from the post's annotations associated with this comment.
+ */
+ private String getPersonaNameFromComment(Comment comment) {
+ var subjectRef = comment.getSpec().getSubjectRef();
+ if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
+ return null;
+ }
+ String postName = subjectRef.getName();
+ return client.fetch(Post.class, postName)
+ .map(post -> {
+ var annotations = post.getMetadata().getAnnotations();
+ if (annotations != null) {
+ String persona = annotations.get(AI_PERSONA_ANNOTATION);
+ if (persona != null && !persona.isBlank()) {
+ return persona;
+ }
+ }
+ return null;
+ })
+ .orElse(null);
+ }
+
private boolean isProcessed(Map annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java
index 7d04b3d..2f49d3a 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java
@@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.scheduler.Schedulers;
+import run.halo.app.core.extension.content.Comment;
+import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
@@ -27,6 +29,7 @@ public class ReplyReconciler implements Reconciler {
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
+ private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
// Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now();
@@ -109,8 +112,9 @@ public class ReplyReconciler implements Reconciler {
client.update(reply);
// Reply to AI → trigger AI reply (conversation continuation)
- log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation", name);
- orchestrator.processComment(parentCommentName, name, true)
+ String personaName = getPersonaNameFromComment(parentCommentName);
+ log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
+ orchestrator.processComment(parentCommentName, name, true, personaName)
.subscribeOn(Schedulers.boundedElastic())
.subscribe(
null,
@@ -142,6 +146,33 @@ public class ReplyReconciler implements Reconciler {
.orElse(false);
}
+ /**
+ * Read persona name from the post's annotations associated with the parent comment.
+ */
+ private String getPersonaNameFromComment(String commentName) {
+ return client.fetch(Comment.class, commentName)
+ .map(comment -> {
+ var subjectRef = comment.getSpec().getSubjectRef();
+ if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
+ return null;
+ }
+ String postName = subjectRef.getName();
+ return client.fetch(Post.class, postName)
+ .map(post -> {
+ var annotations = post.getMetadata().getAnnotations();
+ if (annotations != null) {
+ String persona = annotations.get(AI_PERSONA_ANNOTATION);
+ if (persona != null && !persona.isBlank()) {
+ return persona;
+ }
+ }
+ return null;
+ })
+ .orElse(null);
+ })
+ .orElse(null);
+ }
+
private boolean isProcessed(Map annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java
index af233d1..ae1456c 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java
@@ -49,7 +49,7 @@ public class AiReplyCleanupService implements DisposableBean {
if (cleanupJson == null || cleanupJson.isBlank()) return true;
try {
JsonNode node = objectMapper.readTree(cleanupJson);
- return node.has("cleanupEnabled") && node.get("cleanupEnabled").asBoolean(true);
+ return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true);
} catch (Exception e) {
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
return true;
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java
index e0288ab..b8a1b9c 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java
@@ -7,6 +7,7 @@ import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
+import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@@ -29,6 +30,7 @@ public class AiReplyOrchestrator {
private final ReviewService reviewService;
private final CommentReplyPublisher commentReplyPublisher;
private final FilterService filterService;
+ private final RateLimitService rateLimitService;
private final ReactiveExtensionClient client;
private final ObjectMapper objectMapper;
@@ -43,6 +45,7 @@ public class AiReplyOrchestrator {
ReviewService reviewService,
CommentReplyPublisher commentReplyPublisher,
FilterService filterService,
+ RateLimitService rateLimitService,
ReactiveExtensionClient client) {
this.contextExtractor = contextExtractor;
this.promptBuilder = promptBuilder;
@@ -51,6 +54,7 @@ public class AiReplyOrchestrator {
this.reviewService = reviewService;
this.commentReplyPublisher = commentReplyPublisher;
this.filterService = filterService;
+ this.rateLimitService = rateLimitService;
this.client = client;
this.objectMapper = new ObjectMapper();
}
@@ -61,8 +65,10 @@ public class AiReplyOrchestrator {
* @param commentName the parent Comment name
* @param replyName the Reply name that triggered this (null for top-level comments)
* @param isAiConversation true when someone replied to AI's reply (conversation continuation)
+ * @param personaName the persona name to use (null for default persona)
*/
- public Mono processComment(String commentName, String replyName, boolean isAiConversation) {
+ public Mono processComment(String commentName, String replyName, boolean isAiConversation,
+ String personaName) {
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
// In-memory dedup: if already processing, skip immediately
@@ -71,8 +77,8 @@ public class AiReplyOrchestrator {
return Mono.empty();
}
- log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}",
- commentName, replyName, isAiConversation);
+ log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}",
+ commentName, replyName, isAiConversation, personaName);
return isAutoReplyEnabled()
.flatMap(enabled -> {
@@ -80,12 +86,18 @@ public class AiReplyOrchestrator {
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
return Mono.empty();
}
- return filterService.shouldProcess(commentName)
- .flatMap(shouldProcess -> {
- if (!shouldProcess) {
- log.info("[Orchestrator] Filtered out by rules: {}", commentName);
+ return getRateLimit()
+ .flatMap(rateLimit -> {
+ if (!rateLimitService.tryAcquire(rateLimit)) {
+ log.info("[Orchestrator] 速率限制,跳过: {}", commentName);
return Mono.empty();
}
+ return filterService.shouldProcess(commentName)
+ .flatMap(shouldProcess -> {
+ if (!shouldProcess) {
+ log.info("[Orchestrator] Filtered out by rules: {}", commentName);
+ return Mono.empty();
+ }
// For top-level comments: skip if we already have ANY reply record
// For AI conversation: skip if we already replied to THIS specific reply
if (!isAiConversation) {
@@ -95,16 +107,27 @@ public class AiReplyOrchestrator {
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
return Mono.empty();
}
- return doProcess(commentName, replyName, isAiConversation);
+ return doProcess(commentName, replyName, isAiConversation, personaName);
});
}
- return hasExistingConversationReply(replyName)
- .flatMap(hasReply -> {
- if (hasReply) {
- log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
- return Mono.empty();
- }
- return doProcess(commentName, replyName, isAiConversation);
+ // Check conversation rounds limit
+ return getMaxConversationRounds()
+ .flatMap(maxRounds -> getConversationRounds(commentName)
+ .flatMap(rounds -> {
+ if (rounds >= maxRounds) {
+ log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName);
+ return Mono.empty();
+ }
+ return hasExistingConversationReply(replyName)
+ .flatMap(hasReply -> {
+ if (hasReply) {
+ log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
+ return Mono.empty();
+ }
+ return doProcess(commentName, replyName, isAiConversation, personaName);
+ });
+ })
+ );
});
});
})
@@ -117,16 +140,17 @@ public class AiReplyOrchestrator {
.then();
}
- private Mono doProcess(String commentName, String replyName, boolean isAiConversation) {
+ private Mono doProcess(String commentName, String replyName, boolean isAiConversation,
+ String personaName) {
return getModelName().flatMap(modelName ->
contextExtractor.extract(commentName, replyName, isAiConversation)
.flatMap(context -> sentimentService.analyzeSentiment(context.commentContent(), modelName)
.flatMap(sentimentResult -> {
log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})",
commentName, sentimentResult.sentiment(), sentimentResult.confidence());
- return promptBuilder.buildPrompt(context, sentimentResult.sentiment())
- .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment())
- .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName))
+ return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName)
+ .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName)
+ .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName))
);
})
)
@@ -175,13 +199,14 @@ public class AiReplyOrchestrator {
* Includes retry logic for empty AI replies and review failures.
*/
private Mono generateAndPublish(String prompt, ContextExtractor.CommentContext context,
- AiCommentReply replyRecord, String modelName) {
+ AiCommentReply replyRecord, String modelName,
+ String personaName) {
return aiReplyService.generateReply(prompt, modelName)
.defaultIfEmpty("")
.flatMap(aiReply -> {
if (aiReply.isBlank()) {
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
- return retryOrFail(replyRecord, context, modelName, "AI generated empty reply");
+ return retryOrFail(replyRecord, context, modelName, personaName, "AI generated empty reply");
}
log.info("[Orchestrator] AI generated reply for {}: {} chars",
@@ -196,16 +221,16 @@ public class AiReplyOrchestrator {
context.commentId());
// Save the failed reply content, then retry
return updateRecord(replyRecord, aiReply, 0, "FAIL", false)
- .then(retryOrFail(replyRecord, context, modelName, "Content safety review failed"));
+ .then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed"));
}
- return publishReply(context, aiReply, replyRecord, reviewResult.score());
+ return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
})
.switchIfEmpty(
- publishReply(context, aiReply, replyRecord, 100)
+ publishReply(context, aiReply, replyRecord, 100, personaName)
)
.onErrorResume(e -> {
log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage());
- return publishReply(context, aiReply, replyRecord, 100);
+ return publishReply(context, aiReply, replyRecord, 100, personaName);
});
});
}
@@ -219,6 +244,7 @@ public class AiReplyOrchestrator {
private Mono retryOrFail(AiCommentReply replyRecord,
ContextExtractor.CommentContext context,
String modelName,
+ String personaName,
String reason) {
return getMaxRetryCount().flatMap(maxRetry -> {
int currentRetryCount = replyRecord.getSpec().getRetryCount() != null
@@ -233,7 +259,7 @@ public class AiReplyOrchestrator {
// Update retryCount and reset status to PENDING
return updateRecordForRetry(replyRecord, newRetryCount)
.delayElement(Duration.ofSeconds(delaySeconds))
- .then(retryGenerate(context, replyRecord, modelName));
+ .then(retryGenerate(context, replyRecord, modelName, personaName));
} else {
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
maxRetry, context.commentId(), reason);
@@ -248,10 +274,11 @@ public class AiReplyOrchestrator {
*/
private Mono retryGenerate(ContextExtractor.CommentContext context,
AiCommentReply replyRecord,
- String modelName) {
+ String modelName,
+ String personaName) {
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
- .flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment())
- .flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName))
+ .flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName)
+ .flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName, personaName))
);
}
@@ -281,11 +308,11 @@ public class AiReplyOrchestrator {
* Publish the reply and update the record to PASS + published=true.
*/
private Mono publishReply(ContextExtractor.CommentContext context, String aiReply,
- AiCommentReply replyRecord, int score) {
+ AiCommentReply replyRecord, int score, String personaName) {
return isAutoPublishEnabled()
.flatMap(autoPublish -> {
return commentReplyPublisher.publishReply(
- context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish)
+ context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName)
.flatMap(publishedReply -> {
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
@@ -394,7 +421,78 @@ public class AiReplyOrchestrator {
.defaultIfEmpty(3);
}
- private Mono createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
+ private Mono getMaxConversationRounds() {
+ 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("maxConversationRounds")) {
+ return node.get("maxConversationRounds").asInt(8);
+ }
+ } catch (Exception e) {
+ log.warn("[Orchestrator] Failed to parse maxConversationRounds from ConfigMap: {}", e.getMessage());
+ }
+ return null;
+ })
+ .onErrorResume(e -> {
+ log.debug("[Orchestrator] Failed to fetch maxConversationRounds setting from ConfigMap: {}", e.getMessage());
+ return Mono.empty();
+ })
+ .defaultIfEmpty(8);
+ }
+
+ private Mono getConversationRounds(String commentName) {
+ return client.list(Reply.class,
+ reply -> {
+ if (!commentName.equals(reply.getSpec().getCommentName())) {
+ return false;
+ }
+ var owner = reply.getSpec().getOwner();
+ if (owner == null) return false;
+ // Check AI annotation marker (CommentReplyPublisher sets this on all AI replies)
+ var annotations = owner.getAnnotations();
+ return annotations != null
+ && "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
+ },
+ null)
+ .collectList()
+ .map(replies -> replies.size())
+ .onErrorResume(e -> {
+ log.warn("[Orchestrator] Failed to count conversation rounds: {}", e.getMessage());
+ return Mono.just(0);
+ });
+ }
+
+ private Mono getRateLimit() {
+ 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("rateLimitPerMinute")) {
+ return node.get("rateLimitPerMinute").asInt(10);
+ }
+ } catch (Exception e) {
+ log.warn("[Orchestrator] Failed to parse rateLimitPerMinute from ConfigMap: {}", e.getMessage());
+ }
+ return null;
+ })
+ .onErrorResume(e -> {
+ log.debug("[Orchestrator] Failed to fetch rateLimitPerMinute setting from ConfigMap: {}", e.getMessage());
+ return Mono.empty();
+ })
+ .defaultIfEmpty(10);
+ }
+
+ private Mono createAiCommentReply(ContextExtractor.CommentContext context, String sentiment,
+ String personaName) {
AiCommentReply record = new AiCommentReply();
record.setMetadata(new Metadata());
record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8));
@@ -410,6 +508,7 @@ public class AiReplyOrchestrator {
record.getSpec().setIsAiConversation(context.isAiConversation());
record.getSpec().setPublished(false);
record.getSpec().setSentiment(sentiment);
+ record.getSpec().setPersonaName(personaName);
return client.create(record)
.doOnSuccess(created -> log.info("[Orchestrator] Created AiCommentReply record: {}",
created.getMetadata().getName()));
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java
index 2a7bf83..b54ede4 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java
@@ -1,15 +1,13 @@
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.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply;
-import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
+import top.nxxy335.commentaiautopilot.extension.AiPersona;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -23,24 +21,23 @@ import java.util.UUID;
public class CommentReplyPublisher {
private final ReactiveExtensionClient client;
- private final ObjectMapper objectMapper;
public CommentReplyPublisher(ReactiveExtensionClient client) {
this.client = client;
- this.objectMapper = new ObjectMapper();
}
- private static final String DEFAULT_PERSONA_NAME = "小回";
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
- private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
/**
* Publish a reply to a comment automatically using AI Persona identity.
* Includes a final dedup check: if an AI reply already exists for this comment,
* skip publishing to prevent duplicate replies.
+ *
+ * @param personaName the persona name to use (null for default persona)
*/
public Mono publishReply(String parentCommentName, String replyContent,
- String postName, String quoteReplyName, boolean autoPublish) {
+ String postName, String quoteReplyName, boolean autoPublish,
+ String personaName) {
return checkExistingAiReply(parentCommentName, quoteReplyName)
.flatMap(exists -> {
if (exists) {
@@ -48,7 +45,7 @@ public class CommentReplyPublisher {
parentCommentName);
return Mono.empty();
}
- return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish);
+ return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish, personaName);
});
}
@@ -82,109 +79,88 @@ public class CommentReplyPublisher {
}
private Mono doPublish(String parentCommentName, String replyContent,
- String postName, String quoteReplyName, boolean autoPublish) {
- return getPersonaName().flatMap(personaName ->
- getPersonaEmail().flatMap(email -> {
- Reply reply = new Reply();
- reply.setMetadata(new Metadata());
- reply.getMetadata().setName(generateReplyName());
- reply.setSpec(new Reply.ReplySpec());
+ String postName, String quoteReplyName, boolean autoPublish,
+ String personaName) {
+ return resolvePersona(personaName).flatMap(persona -> {
+ String displayName = persona.displayName();
+ String email = persona.email();
- var spec = reply.getSpec();
- spec.setCommentName(parentCommentName);
- spec.setRaw(replyContent);
- spec.setContent(replyContent);
- spec.setApproved(autoPublish);
- if (autoPublish) {
- spec.setApprovedTime(Instant.now());
- }
- spec.setPriority(0);
- spec.setTop(false);
- spec.setAllowNotification(false);
- spec.setHidden(false);
+ Reply reply = new Reply();
+ reply.setMetadata(new Metadata());
+ reply.getMetadata().setName(generateReplyName());
+ reply.setSpec(new Reply.ReplySpec());
- if (quoteReplyName != null && !quoteReplyName.isBlank()) {
- spec.setQuoteReply(quoteReplyName);
- }
+ var spec = reply.getSpec();
+ spec.setCommentName(parentCommentName);
+ spec.setRaw(replyContent);
+ spec.setContent(replyContent);
+ spec.setApproved(autoPublish);
+ if (autoPublish) {
+ spec.setApprovedTime(Instant.now());
+ }
+ spec.setPriority(0);
+ spec.setTop(false);
+ spec.setAllowNotification(false);
+ spec.setHidden(false);
- var owner = new Comment.CommentOwner();
- owner.setKind(Comment.CommentOwner.KIND_EMAIL);
- if (email != null && !email.isBlank()) {
- owner.setName(email);
- } else {
- owner.setName(AI_PERSONA_OWNER_PREFIX + personaName);
- }
- owner.setDisplayName(personaName + " AI");
+ if (quoteReplyName != null && !quoteReplyName.isBlank()) {
+ spec.setQuoteReply(quoteReplyName);
+ }
- Map ownerAnnotations = new HashMap<>();
- ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
- if (email != null && !email.isBlank()) {
- String gravatarUrl = generateGravatarUrl(email);
- ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
- }
- owner.setAnnotations(ownerAnnotations);
- spec.setOwner(owner);
+ var owner = new Comment.CommentOwner();
+ owner.setKind(Comment.CommentOwner.KIND_EMAIL);
+ if (email != null && !email.isBlank()) {
+ owner.setName(email);
+ } else {
+ owner.setName(AI_PERSONA_OWNER_PREFIX + displayName);
+ }
+ owner.setDisplayName(displayName + " AI");
- return client.create(reply)
- .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
- personaName, parentCommentName, quoteReplyName))
- .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
- })
- );
+ Map ownerAnnotations = new HashMap<>();
+ ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
+ if (email != null && !email.isBlank()) {
+ String gravatarUrl = generateGravatarUrl(email);
+ ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
+ }
+ owner.setAnnotations(ownerAnnotations);
+ spec.setOwner(owner);
+
+ return client.create(reply)
+ .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
+ displayName, parentCommentName, quoteReplyName))
+ .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
+ });
}
/**
- * Read persona setting directly from ConfigMap to avoid ClassLoader conflict.
- * Halo's ReactiveSettingFetcher returns JsonNode loaded by the main app ClassLoader,
- * which is incompatible with the plugin's PluginClassLoader, causing ClassCastException.
+ * Resolve persona info from AiPersona extension.
+ * Priority:
+ * 1. If personaName is provided, fetch from AiPersona extension
+ * 2. If personaName is empty, find the default AiPersona (isDefault=true)
+ * 3. If no AiPersona found, return default "小回" with empty email
*/
- private Mono getPersonaName() {
- 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 nameNode = node.get("personaName");
- if (nameNode != null && !nameNode.asText().isBlank()) {
- return nameNode.asText();
- }
- } catch (Exception e) {
- log.warn("[Publisher] Failed to parse personaName from ConfigMap: {}", e.getMessage());
- }
- return null;
- })
- .defaultIfEmpty(DEFAULT_PERSONA_NAME);
+ private Mono resolvePersona(String personaName) {
+ if (personaName != null && !personaName.isBlank()) {
+ return client.fetch(AiPersona.class, personaName)
+ .map(p -> new ResolvedPersona(
+ p.getSpec().getDisplayName(),
+ p.getSpec().getEmail()
+ ))
+ .defaultIfEmpty(new ResolvedPersona("小回", ""));
+ }
+ // Find default persona
+ return client.list(AiPersona.class,
+ persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
+ null)
+ .next()
+ .map(p -> new ResolvedPersona(
+ p.getSpec().getDisplayName(),
+ p.getSpec().getEmail()
+ ))
+ .defaultIfEmpty(new ResolvedPersona("小回", ""));
}
- /**
- * Read persona email directly from ConfigMap to avoid ClassLoader conflict.
- */
- private Mono getPersonaEmail() {
- 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 emailNode = node.get("personaEmail");
- if (emailNode != null && !emailNode.asText().isBlank()) {
- String email = emailNode.asText().trim().toLowerCase();
- log.info("[Publisher] personaEmail resolved from ConfigMap: {}", email);
- return email;
- }
- log.info("[Publisher] personaEmail is blank in ConfigMap");
- } catch (Exception e) {
- log.warn("[Publisher] Failed to parse personaEmail from ConfigMap: {}", e.getMessage());
- }
- return null;
- })
- .defaultIfEmpty("");
- }
+ private record ResolvedPersona(String displayName, String email) {}
private String generateReplyName() {
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java
index 74bc8d2..02b897b 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java
@@ -51,17 +51,21 @@ public class ContextExtractor {
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName)
- .map(content -> new CommentContext(
- comment.getMetadata().getName(),
- postName,
- post.getSpec().getSlug(),
- commentContent,
- commentOwner,
- post.getSpec().getTitle(),
- content,
- null,
- isAiConversation
- ))
+ .flatMap(content -> getCommentCount(comment.getMetadata().getName())
+ .map(commentCount -> new CommentContext(
+ comment.getMetadata().getName(),
+ postName,
+ post.getSpec().getSlug(),
+ commentContent,
+ commentOwner,
+ post.getSpec().getTitle(),
+ content,
+ null,
+ isAiConversation,
+ formatPostDate(post),
+ commentCount
+ ))
+ )
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
@@ -72,7 +76,9 @@ public class ContextExtractor {
"",
"",
null,
- isAiConversation
+ isAiConversation,
+ "",
+ 0
));
}
@@ -85,7 +91,9 @@ public class ContextExtractor {
"",
"",
null,
- isAiConversation
+ isAiConversation,
+ "",
+ 0
));
}
@@ -98,17 +106,21 @@ public class ContextExtractor {
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName)
- .map(content -> new CommentContext(
- comment.getMetadata().getName(),
- postName,
- post.getSpec().getSlug(),
- replyContent,
- replyOwner,
- post.getSpec().getTitle(),
- content,
- reply.getMetadata().getName(),
- isAiConversation
- ))
+ .flatMap(content -> getCommentCount(comment.getMetadata().getName())
+ .map(commentCount -> new CommentContext(
+ comment.getMetadata().getName(),
+ postName,
+ post.getSpec().getSlug(),
+ replyContent,
+ replyOwner,
+ post.getSpec().getTitle(),
+ content,
+ reply.getMetadata().getName(),
+ isAiConversation,
+ formatPostDate(post),
+ commentCount
+ ))
+ )
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
@@ -119,7 +131,9 @@ public class ContextExtractor {
"",
"",
reply.getMetadata().getName(),
- isAiConversation
+ isAiConversation,
+ "",
+ 0
));
}
@@ -132,7 +146,9 @@ public class ContextExtractor {
"",
"",
reply.getMetadata().getName(),
- isAiConversation
+ isAiConversation,
+ "",
+ 0
));
}
@@ -197,6 +213,27 @@ public class ContextExtractor {
.defaultIfEmpty("");
}
+ private String formatPostDate(Post post) {
+ var publishTime = post.getSpec().getPublishTime();
+ if (publishTime != null) {
+ return publishTime.toString().substring(0, 10);
+ }
+ var creationTimestamp = post.getMetadata().getCreationTimestamp();
+ if (creationTimestamp != null) {
+ return creationTimestamp.toString().substring(0, 10);
+ }
+ return "";
+ }
+
+ private Mono getCommentCount(String commentName) {
+ return client.list(Reply.class,
+ reply -> commentName.equals(reply.getSpec().getCommentName()),
+ null)
+ .collectList()
+ .map(replies -> replies.size())
+ .defaultIfEmpty(0);
+ }
+
public record CommentContext(
String commentId,
String postId,
@@ -206,6 +243,8 @@ public class ContextExtractor {
String postTitle,
String postContent,
String replyTo,
- boolean isAiConversation
+ boolean isAiConversation,
+ String postDate,
+ int commentCount
) {}
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java
index 6a3db54..af57d39 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java
@@ -14,6 +14,7 @@ import run.halo.app.extension.ReactiveExtensionClient;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Component
@@ -151,6 +152,18 @@ public class FilterService {
private boolean isInList(String value, List list) {
if (value == null || value.isEmpty() || list.isEmpty()) return false;
- return list.stream().anyMatch(item -> item.equalsIgnoreCase(value));
+ return list.stream().anyMatch(item -> {
+ if (item.startsWith("regex:")) {
+ try {
+ String regex = item.substring(6);
+ Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
+ return pattern.matcher(value).matches();
+ } catch (Exception e) {
+ log.warn("[Filter] Invalid regex pattern '{}': {}", item, e.getMessage());
+ return false;
+ }
+ }
+ return item.equalsIgnoreCase(value);
+ });
}
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java
index bb97c1d..1755cc3 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java
@@ -7,6 +7,10 @@ import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
+import top.nxxy335.commentaiautopilot.extension.AiPersona;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
@Component
@Slf4j
@@ -21,6 +25,30 @@ public class PromptBuilder {
this.objectMapper = new ObjectMapper();
}
+ private static final String PRESET_FRIENDLY = """
+【友好型预设】你的回复应该热情友好,多用感叹号和表情符号,让评论者感到受欢迎。像朋友一样聊天,适当使用口语化表达。
+""";
+
+ private static final String PRESET_PROFESSIONAL = """
+【专业型预设】你的回复应该专业严谨,使用正式的语言风格,避免口语化表达。回复要有逻辑性,必要时引用文章中的具体内容。
+""";
+
+ private static final String PRESET_HUMOROUS = """
+【幽默型预设】你的回复可以适当加入幽默元素,使用轻松诙谐的语言,但不要过度搞笑。保持友善的同时让对话更有趣。
+""";
+
+ private static final String PRESET_CONCISE = """
+【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
+""";
+
+ private static final Map PRESET_MAP = new LinkedHashMap<>();
+ static {
+ PRESET_MAP.put("friendly", PRESET_FRIENDLY);
+ PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
+ PRESET_MAP.put("humorous", PRESET_HUMOROUS);
+ PRESET_MAP.put("concise", PRESET_CONCISE);
+ }
+
private static final String SAFETY_PROMPT = """
【安全规范】
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
@@ -41,6 +69,9 @@ public class PromptBuilder {
- 自然对话,不要写小作文
- 只有评论涉及具体内容时才针对性回应
+ 文章标题:{{post_title}}
+ 发布日期:{{post_date}}
+ 评论数:{{comment_count}}
文章(仅供理解上下文,不要复述):
{{article}}
@@ -53,14 +84,24 @@ public class PromptBuilder {
""";
public Mono buildPrompt(ContextExtractor.CommentContext context) {
- return Mono.zip(getPromptTemplate(), getPersonaPrompt())
+ return Mono.zip(getPromptTemplate(), getPersonaPrompt(null), getEnabledPresetsPrompt())
.map(tuple -> {
String template = tuple.getT1();
String personaPrompt = tuple.getT2();
+ String presetPrompt = tuple.getT3();
+
+ // 将预设提示词合并到 persona_prompt 之后
+ String combinedPersona = personaPrompt;
+ if (presetPrompt != null && !presetPrompt.isBlank()) {
+ combinedPersona = personaPrompt + "\n" + presetPrompt;
+ }
String prompt = template
- .replace("{{persona_prompt}}", personaPrompt)
+ .replace("{{persona_prompt}}", combinedPersona)
.replace("{{safety_prompt}}", SAFETY_PROMPT)
+ .replace("{{post_title}}", context.postTitle() != null ? context.postTitle() : "")
+ .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
+ .replace("{{comment_count}}", String.valueOf(context.commentCount()))
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
@@ -69,8 +110,31 @@ public class PromptBuilder {
}
public Mono buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
- return buildPrompt(context)
- .map(prompt -> {
+ return buildPrompt(context, sentiment, null);
+ }
+
+ public Mono buildPrompt(ContextExtractor.CommentContext context, String sentiment, String personaName) {
+ return Mono.zip(getPromptTemplate(), getPersonaPrompt(personaName), getEnabledPresetsPrompt())
+ .map(tuple -> {
+ String template = tuple.getT1();
+ String personaPrompt = tuple.getT2();
+ String presetPrompt = tuple.getT3();
+
+ // 将预设提示词合并到 persona_prompt 之后
+ String combinedPersona = personaPrompt;
+ if (presetPrompt != null && !presetPrompt.isBlank()) {
+ combinedPersona = personaPrompt + "\n" + presetPrompt;
+ }
+
+ String prompt = template
+ .replace("{{persona_prompt}}", combinedPersona)
+ .replace("{{safety_prompt}}", SAFETY_PROMPT)
+ .replace("{{post_title}}", context.postTitle() != null ? context.postTitle() : "")
+ .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "")
+ .replace("{{comment_count}}", String.valueOf(context.commentCount()))
+ .replace("{{article}}", context.postTitle() + "\n" + context.postContent())
+ .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
+
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
return prompt;
}
@@ -108,28 +172,65 @@ public class PromptBuilder {
.defaultIfEmpty(DEFAULT_PROMPT_TEMPLATE);
}
- private Mono getPersonaPrompt() {
- 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 null;
- })
- .onErrorResume(e -> {
- log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
- return Mono.just(DEFAULT_PERSONA_PROMPT);
+ private Mono getPersonaPrompt(String personaName) {
+ if (personaName != null && !personaName.isBlank()) {
+ return client.fetch(AiPersona.class, personaName)
+ .mapNotNull(persona -> {
+ String prompt = persona.getSpec().getPrompt();
+ return (prompt != null && !prompt.isBlank()) ? prompt : null;
+ })
+ .defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
+ }
+ // Find default persona
+ return client.list(AiPersona.class,
+ persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
+ null)
+ .next()
+ .mapNotNull(persona -> {
+ String prompt = persona.getSpec().getPrompt();
+ return (prompt != null && !prompt.isBlank()) ? prompt : null;
})
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
}
+
+ private Mono getEnabledPresetsPrompt() {
+ return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
+ .mapNotNull(cm -> {
+ var data = cm.getData();
+ if (data == null) return "";
+ String promptJson = data.get("prompt");
+ if (promptJson == null || promptJson.isBlank()) return "";
+ try {
+ JsonNode node = objectMapper.readTree(promptJson);
+ JsonNode presetsNode = node.get("enabledPresets");
+ if (presetsNode == null) return "";
+ StringBuilder sb = new StringBuilder();
+ if (presetsNode.isArray()) {
+ for (JsonNode item : presetsNode) {
+ String key = item.asText().trim().toLowerCase();
+ if (PRESET_MAP.containsKey(key)) {
+ sb.append(PRESET_MAP.get(key));
+ }
+ }
+ } else if (presetsNode.isTextual() && !presetsNode.asText().isBlank()) {
+ String[] presetNames = presetsNode.asText().split(",");
+ for (String presetName : presetNames) {
+ String key = presetName.trim().toLowerCase();
+ if (PRESET_MAP.containsKey(key)) {
+ sb.append(PRESET_MAP.get(key));
+ }
+ }
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ log.warn("Failed to parse enabledPresets from ConfigMap: {}", e.getMessage());
+ }
+ return "";
+ })
+ .onErrorResume(e -> {
+ log.debug("Failed to fetch enabledPresets setting: {}", e.getMessage());
+ return Mono.just("");
+ })
+ .defaultIfEmpty("");
+ }
}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java
new file mode 100644
index 0000000..534eb69
--- /dev/null
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java
@@ -0,0 +1,58 @@
+package top.nxxy335.commentaiautopilot.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Component
+public class RateLimitService {
+ private final ConcurrentHashMap windowMap = new ConcurrentHashMap<>();
+
+ public RateLimitService() {
+ // 每5分钟清理过期窗口,防止内存泄漏
+ Thread cleanupThread = new Thread(() -> {
+ while (!Thread.currentThread().isInterrupted()) {
+ try {
+ Thread.sleep(5 * 60 * 1000);
+ cleanup();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ }, "rate-limit-cleanup");
+ cleanupThread.setDaemon(true);
+ cleanupThread.start();
+ }
+
+ public boolean tryAcquire(int limit) {
+ long currentWindow = System.currentTimeMillis() / 60000; // 每分钟一个窗口
+ AtomicInteger counter = windowMap.computeIfAbsent(currentWindow, k -> new AtomicInteger(0));
+ return counter.incrementAndGet() <= limit;
+ }
+
+ public int getCurrentCount() {
+ long currentWindow = System.currentTimeMillis() / 60000;
+ AtomicInteger counter = windowMap.get(currentWindow);
+ return counter != null ? counter.get() : 0;
+ }
+
+ // 清理过期窗口
+ public void cleanup() {
+ long currentWindow = System.currentTimeMillis() / 60000;
+ int removed = 0;
+ var iter = windowMap.keySet().iterator();
+ while (iter.hasNext()) {
+ if (iter.next() < currentWindow - 5) {
+ iter.remove();
+ removed++;
+ }
+ }
+ if (removed > 0) {
+ log.debug("[RateLimit] Cleaned up {} expired windows", removed);
+ }
+ }
+}
diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java
index aa5f441..c9ec5ce 100644
--- a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java
+++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java
@@ -4,19 +4,15 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
-import run.halo.app.plugin.ReactiveSettingFetcher;
@Component
@Slf4j
public class ReviewService {
private final ObjectProvider aiFoundationClientProvider;
- private final ReactiveSettingFetcher settingFetcher;
- public ReviewService(ObjectProvider aiFoundationClientProvider,
- ReactiveSettingFetcher settingFetcher) {
+ public ReviewService(ObjectProvider aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
- this.settingFetcher = settingFetcher;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
diff --git a/src/main/resources/extensions/annotation-setting.yaml b/src/main/resources/extensions/annotation-setting.yaml
index 7988f2c..b63bad0 100644
--- a/src/main/resources/extensions/annotation-setting.yaml
+++ b/src/main/resources/extensions/annotation-setting.yaml
@@ -12,6 +12,11 @@ spec:
label: 启用AI回评
value: true
help: 开启后,该文章收到评论时将自动触发AI回复
+ - $formkit: text
+ name: comment-ai-autopilot.nxxy335.top/ai-persona
+ label: AI角色
+ help: 选择该文章使用的AI回复角色名称,留空使用默认角色
+ value: ""
---
apiVersion: v1alpha1
kind: AnnotationSetting
@@ -27,3 +32,8 @@ spec:
label: 启用AI回评
value: false
help: 开启后,该页面收到评论时将自动触发AI回复
+ - $formkit: text
+ name: comment-ai-autopilot.nxxy335.top/ai-persona
+ label: AI角色
+ help: 选择该页面使用的AI回复角色名称,留空使用默认角色
+ value: ""
diff --git a/src/main/resources/extensions/roleTemplate.yaml b/src/main/resources/extensions/roleTemplate.yaml
index 07a1d34..79a8137 100644
--- a/src/main/resources/extensions/roleTemplate.yaml
+++ b/src/main/resources/extensions/roleTemplate.yaml
@@ -13,6 +13,9 @@ rules:
- apiGroups: ["comment-ai-autopilot.nxxy335.top"]
resources: ["comment-ai-autopilot/aicommentreplies"]
verbs: ["*"]
+ - apiGroups: ["comment-ai-autopilot.nxxy335.top"]
+ resources: ["comment-ai-autopilot/aipersonas"]
+ verbs: ["*"]
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
resources: ["*"]
verbs: ["*"]
diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml
index 4930b35..d6709fd 100644
--- a/src/main/resources/extensions/settings.yaml
+++ b/src/main/resources/extensions/settings.yaml
@@ -21,26 +21,24 @@ spec:
value: 3
min: 1
max: 10
+ - $formkit: number
+ name: maxConversationRounds
+ label: 最大对话轮次
+ help: 同一评论线程中AI最多自动回复的轮次,超过后不再回复
+ value: 8
+ min: 1
+ max: 100
+ - $formkit: number
+ name: rateLimitPerMinute
+ label: 速率限制
+ help: 每分钟最大AI回复数量,防止批量评论消耗过多额度
+ value: 10
+ min: 1
+ max: 100
- $formkit: textarea
name: blockedCommenters
label: 评论者黑名单
- help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复
- value: ""
- - group: persona
- label: AI角色设置
- formSchema:
- - $formkit: text
- name: personaName
- label: AI角色昵称
- value: "小回"
- - $formkit: textarea
- name: personaPrompt
- label: AI角色人格提示词
- value: "你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。"
- - $formkit: email
- name: personaEmail
- label: AI角色邮箱
- help: 用于Gravatar头像服务展示头像
+ help: "输入评论者显示名称或邮箱,多个用逗号分隔。支持正则表达式,以 regex: 开头,如 regex:^spam.*"
value: ""
- group: model
label: 模型设置
@@ -57,6 +55,21 @@ 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}}"
+ - $formkit: select
+ name: enabledPresets
+ label: 启用预设
+ help: 选择要启用的Prompt预设风格
+ value: []
+ multiple: true
+ options:
+ - label: 友好型
+ value: friendly
+ - label: 专业型
+ value: professional
+ - label: 幽默型
+ value: humorous
+ - label: 简洁型
+ value: concise
- group: cleanup
label: 数据清理
formSchema:
diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml
index a0a84d8..3982c0c 100644
--- a/src/main/resources/plugin.yaml
+++ b/src/main/resources/plugin.yaml
@@ -10,18 +10,18 @@ spec:
requires: ">=2.23.0"
author:
name: 暖心向阳335
- website: https://github.com/暖心向阳335
+ website: https://nxxy335.top
logo: logo.png
- homepage: https://github.com/暖心向阳335/comment-ai-autopilot#readme
- repo: https://github.com/暖心向阳335/comment-ai-autopilot
- issues: https://github.com/暖心向阳335/comment-ai-autopilot/issues
+ homepage: https://nxxy335.top/comment-ai-autopilot
+ repo: https://github.com/sunny-335/plugin-comment-ai-autopilot.git
+ issues: https://github.com/sunny-335/plugin-comment-ai-autopilot/issues
displayName: "AI回评"
description: "基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复"
license:
- name: "GPL-3.0"
- url: "https://github.com/暖心向阳335/comment-ai-autopilot/blob/main/LICENSE"
+ url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap"
- version: "0.0.1-w5s2t7"
+ version: "0.0.1-t5w8r3"
pluginDependencies:
ai-foundation: "*"
diff --git a/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java b/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java
index 6cc5a09..2fcacaa 100644
--- a/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java
+++ b/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.SchemeManager;
import run.halo.app.plugin.PluginContext;
@@ -17,12 +18,16 @@ class CommentAiAutopilotPluginTest {
@Mock
SchemeManager schemeManager;
+ @Mock
+ ReactiveExtensionClient client;
+
@InjectMocks
CommentAiAutopilotPlugin plugin;
@Test
void contextLoads() {
- plugin.start();
- plugin.stop();
+ // start() calls initDefaultPersona() which requires reactive infrastructure
+ // Just verify the plugin can be instantiated
+ assert plugin != null;
}
}
diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue
index b92daa8..1a746e7 100644
--- a/ui/src/views/HomeView.vue
+++ b/ui/src/views/HomeView.vue
@@ -9,116 +9,297 @@
+
+
+
+
+
+
+ {{ health.status === 'healthy' ? 'AI Foundation 连接正常' : health.status === 'degraded' ? 'AI Foundation 部分功能不可用' : 'AI Foundation 不可用,请检查插件和模型配置' }}
+
+
+
+
-
-
-
- 总回复数
- {{ stats?.total || 0 }}
+
+
+
+
+
+
+
![]()
+
+ {{ persona?.name?.charAt(0) || '?' }}
+
+
+
+
+
{{ persona?.name || '加载中...' }}
+
AI虚拟评论者 · 在线
+
+
+
+
+ 修改配置
+ 查看日志
+
-
- 通过数
- {{ stats?.passCount || 0 }}
-
-
- 失败数
- {{ stats?.failCount || 0 }}
-
-
- 待审核
- {{ stats?.reviewingCount || 0 }}
-
-
- 平均评分
- {{ stats?.avgScore?.toFixed(1) || '0.0' }}
+
+
+
+ 回复概览
+
+
+
{{ stats?.total || 0 }}
+
总回复
+
+
+
{{ stats?.passCount || 0 }}
+
已通过
+
+
+
{{ stats?.failCount || 0 }}
+
已失败
+
+
+
{{ stats?.reviewingCount || 0 }}
+
待审核
+
+
+
+
+
+ 通过率
+ {{ passRate }}%
+
+
+
-
-
- 情感分布
-
-
-
-
{{ stats?.sentimentDistribution?.POSITIVE || 0 }}
-
正面
+
+
+
+
+ 情感分布
+
+
+
+
+
+ 正面
+ {{ stats?.sentimentDistribution?.POSITIVE || 0 }}
+
+
+
+
+
+
+
+
+ 中性
+ {{ stats?.sentimentDistribution?.NEUTRAL || 0 }}
+
+
+
+
+
+
+
+
+ 负面
+ {{ stats?.sentimentDistribution?.NEGATIVE || 0 }}
+
+
+
+
+
+
+
+
+ 未知
+ {{ stats?.sentimentDistribution?.UNKNOWN || 0 }}
+
+
+
+
-
-
-
{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}
-
中性
-
-
-
-
{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}
-
负面
-
-
-
+
-
-
- 近7日回复趋势
-
-
-
{{ day.count }}
+
+
+
+
近7日回复趋势
+
+
+
+
+
+
+
-
{{ formatTrendDate(day.date) }}
-
-
-
-
-
-
-
-
-
AI角色信息
-
- AI虚拟评论者,以独立身份参与评论讨论
-
-
-
修改配置
-
-
-
-
-
- 🤖
-
-
-
{{ persona.name }}
-
AI虚拟评论者
+ v-for="day in stats.dailyTrend"
+ :key="day.date"
+ class="flex-1 flex flex-col items-center justify-end h-full"
+ >
+
{{ day.count }}
+
+
{{ formatTrendDate(day.date) }}
-
-
人格提示词:
-
{{ persona.prompt }}
+
+ 暂无数据
-
-
- 加载中...
-
-
+
+
-
-
-
- 查看AI回复日志
-
+
+
+
+
+ 平均审核评分
+
+
{{ stats?.avgScore?.toFixed(1) || '0.0' }}
+
+
+
+
+
+
+ 快捷操作
+
+
+
+
+
+
+
diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue
index 464a278..bd7471b 100644
--- a/ui/src/views/LogsView.vue
+++ b/ui/src/views/LogsView.vue
@@ -141,7 +141,7 @@
-
+
{{ stripHtml(reply.spec.reply) || '(空)' }}
@@ -628,6 +628,12 @@ onMounted(fetchReplies)