feat: 多AI角色支持、Bug修复、文档更新

This commit is contained in:
sunny-335
2026-06-15 12:05:46 +08:00
parent ef3cdf4bdc
commit 6f1cb4b037
27 changed files with 2400 additions and 755 deletions
@@ -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;
/**
* <p>Plugin main class to manage the lifecycle of the plugin.</p>
@@ -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.<AiCommentReply, String>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));
}
}
@@ -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<AiFoundationClient> 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<AiFoundationClient> 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<ServerResponse> 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<AiCommentReply> 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<LocalDate, Long> 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<DailyCount> 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<ServerResponse> 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<String> 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<Reply> 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<ServerResponse> 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<ServerResponse> listPersonas(ServerRequest request) {
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.flatMap(personas -> ServerResponse.ok().bodyValue(personas));
}
private Mono<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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());
}
}
@@ -56,5 +56,8 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
private String sentiment;
@Schema(description = "使用的AI角色名称")
private String personaName;
}
}
@@ -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;
}
}
@@ -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<Reconciler.Request> {
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();
}
}
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<Reconciler.Request> {
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<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<Reconciler.Request> {
.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<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
@@ -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;
@@ -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<Void> processComment(String commentName, String replyName, boolean isAiConversation) {
public Mono<Void> 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<Void> doProcess(String commentName, String replyName, boolean isAiConversation) {
private Mono<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
private Mono<Integer> 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<Integer> 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<Integer> 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<AiCommentReply> 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()));
@@ -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<Reply> 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<Reply> 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<String, String> 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<String, String> 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<String> 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<ResolvedPersona> 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<String> 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);
@@ -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<Integer> 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
) {}
}
@@ -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<String> 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);
});
}
}
@@ -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<String, String> 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<String> 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<String> buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
return buildPrompt(context)
.map(prompt -> {
return buildPrompt(context, sentiment, null);
}
public Mono<String> 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<String> 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<String> 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<String> 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("");
}
}
@@ -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<Long, AtomicInteger> 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);
}
}
}
@@ -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<AiFoundationClient> aiFoundationClientProvider;
private final ReactiveSettingFetcher settingFetcher;
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider,
ReactiveSettingFetcher settingFetcher) {
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
this.settingFetcher = settingFetcher;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
@@ -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: ""
@@ -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: ["*"]
+30 -17
View File
@@ -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:
+6 -6
View File
@@ -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: "*"