first commit

This commit is contained in:
sunny-335
2026-06-14 22:28:19 +08:00
commit e03ba7a20d
67 changed files with 13400 additions and 0 deletions
@@ -0,0 +1,45 @@
package top.nxxy335.commentaiautopilot;
import org.springframework.stereotype.Component;
import run.halo.app.extension.index.IndexSpecs;
import run.halo.app.extension.Scheme;
import run.halo.app.extension.SchemeManager;
import run.halo.app.plugin.BasePlugin;
import run.halo.app.plugin.PluginContext;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
/**
* <p>Plugin main class to manage the lifecycle of the plugin.</p>
* <p>This class must be public and have a public constructor.</p>
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
*
* @author 暖心向阳335
* @since 1.0.0
*/
@Component
public class CommentAiAutopilotPlugin extends BasePlugin {
private final SchemeManager schemeManager;
public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
super(pluginContext);
this.schemeManager = schemeManager;
}
@Override
public void start() {
schemeManager.register(AiCommentReply.class, indexSpecs -> {
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.commentId", String.class)
.indexFunc(ext -> ext.getSpec().getCommentId()));
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.postId", String.class)
.indexFunc(ext -> ext.getSpec().getPostId()));
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
.indexFunc(ext -> ext.getSpec().getStatus()));
});
}
@Override
public void stop() {
schemeManager.unregister(Scheme.buildFromType(AiCommentReply.class));
}
}
@@ -0,0 +1,498 @@
package top.nxxy335.commentaiautopilot.endpoint;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
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.Reply;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.PageRequestImpl;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.domain.Sort;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
@Component
@Slf4j
public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
private final AiReplyOrchestrator orchestrator;
private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) {
this.client = client;
this.orchestrator = orchestrator;
this.objectMapper = new ObjectMapper();
}
@Override
public RouterFunction<ServerResponse> endpoint() {
return route()
.GET("/replies", this::listReplies)
.POST("/replies/batch-approve", this::batchApproveReplies)
.POST("/replies/batch-reject", this::batchRejectReplies)
.POST("/replies/batch-delete", this::batchDeleteReplies)
.DELETE("/replies/{name}", this::deleteReply)
.GET("/stats", this::getStats)
.GET("/persona", this::getPersona)
.GET("/conversation/{commentName}", this::getConversation)
.POST("/replies/{name}/approve", this::approveReply)
.POST("/replies/{name}/reject", this::rejectReply)
.POST("/comments/{commentName}/trigger", this::triggerReply)
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
.build();
}
@Override
public GroupVersion groupVersion() {
return new GroupVersion("console.api.comment-ai-autopilot.nxxy335.top", "v1alpha1");
}
private Mono<ServerResponse> listReplies(ServerRequest request) {
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
var pageable = PageRequestImpl.of(page, size, sort);
return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable)
.flatMap(result -> ServerResponse.ok().bodyValue(result));
}
private Mono<ServerResponse> deleteReply(ServerRequest request) {
var name = request.pathVariable("name");
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> client.delete(record))
.then(ServerResponse.ok().bodyValue("{\"message\":\"deleted\"}"))
.switchIfEmpty(ServerResponse.notFound().build());
}
private Mono<ServerResponse> getStats(ServerRequest request) {
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.map(replies -> {
long total = replies.size();
long passCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
long failCount = replies.stream()
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
double avgScore = replies.stream()
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
.mapToInt(r -> r.getSpec().getScore())
.average().orElse(0.0);
long reviewingCount = replies.stream()
.filter(r -> "PASS".equals(r.getSpec().getStatus())
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
.count();
Map<String, Long> sentimentDistribution = new HashMap<>();
sentimentDistribution.put("POSITIVE", 0L);
sentimentDistribution.put("NEUTRAL", 0L);
sentimentDistribution.put("NEGATIVE", 0L);
sentimentDistribution.put("UNKNOWN", 0L);
for (var r : replies) {
String sentiment = r.getSpec().getSentiment();
if (sentiment == null || sentiment.isBlank()) {
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
} else {
sentimentDistribution.merge(sentiment, 1L, Long::sum);
}
}
ZoneId zoneId = ZoneId.systemDefault();
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++) {
dailyMap.put(today.minusDays(i), 0L);
}
for (var r : replies) {
Instant timestamp = r.getMetadata().getCreationTimestamp();
if (timestamp != null) {
try {
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
if (dailyMap.containsKey(date)) {
dailyMap.merge(date, 1L, Long::sum);
}
} catch (Exception ignored) {
}
}
}
List<DailyCount> dailyTrend = new ArrayList<>();
for (int i = 0; i < 7; i++) {
LocalDate date = today.minusDays(i);
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
}
return new StatsResponse(total, passCount, failCount, avgScore,
reviewingCount, sentimentDistribution, dailyTrend);
})
.onErrorResume(e -> {
log.warn("Failed to fetch stats: {}", e.getMessage());
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L,
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
List.of()));
})
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
}
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("小回", "", "");
}
})
.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));
}
public record DailyCount(String date, long count) {}
public record StatsResponse(
long total,
long passCount,
long failCount,
double avgScore,
long reviewingCount,
Map<String, Long> sentimentDistribution,
List<DailyCount> dailyTrend
) {}
public record PersonaResponse(
String name,
String prompt,
String avatar
) {}
private Mono<ServerResponse> getConversation(ServerRequest request) {
var commentName = request.pathVariable("commentName");
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var commentOwner = extractOwnerName(comment.getSpec().getOwner());
var commentContent = extractContent(comment.getSpec().getRaw(), comment.getSpec().getContent());
var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp());
var isCommentAi = isAiOwner(comment.getSpec().getOwner());
var commentMsg = new ConversationMessage(
"comment", commentOwner, commentContent, commentTime, isCommentAi
);
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
.map(reply -> {
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
var isAi = isAiOwner(reply.getSpec().getOwner());
return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi);
})
.collectList()
.map(replyList -> {
List<ConversationMessage> messages = new ArrayList<>();
messages.add(commentMsg);
messages.addAll(replyList);
return messages;
});
})
.flatMap(messages -> ServerResponse.ok().bodyValue(Map.of("messages", messages)))
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of("messages", List.of())));
}
private String extractOwnerName(Comment.CommentOwner owner) {
if (owner == null) return "匿名用户";
var displayName = owner.getDisplayName();
return (displayName != null && !displayName.isBlank()) ? displayName : "匿名用户";
}
private String extractContent(String raw, String content) {
if (raw != null && !raw.isBlank()) return raw;
if (content != null && !content.isBlank()) return content;
return "";
}
private boolean isAiOwner(Comment.CommentOwner owner) {
if (owner == null) return false;
var annotations = owner.getAnnotations();
if (annotations != null) {
return "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
}
return false;
}
private Mono<ServerResponse> approveReply(ServerRequest request) {
var name = request.pathVariable("name");
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
// Find the corresponding Reply and set approved=true
return findReplyForRecord(record)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.then(Mono.defer(() -> {
// Update AiCommentReply record
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setPublished(true);
return client.update(latest);
});
}))
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
})
.switchIfEmpty(ServerResponse.notFound().build());
}
private Mono<ServerResponse> rejectReply(ServerRequest request) {
var name = request.pathVariable("name");
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
// Delete the draft Reply if it exists
return findReplyForRecord(record)
.flatMap(reply -> client.delete(reply))
.then(Mono.defer(() -> {
// Update AiCommentReply record status to REJECTED
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
return client.update(latest);
});
}))
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
})
.switchIfEmpty(ServerResponse.notFound().build());
}
private Mono<ServerResponse> batchApproveReplies(ServerRequest request) {
return request.bodyToMono(String.class)
.flatMap(body -> {
List<String> names;
try {
JsonNode node = objectMapper.readTree(body);
names = new ArrayList<>();
node.get("names").forEach(n -> names.add(n.asText()));
} catch (Exception e) {
return ServerResponse.badRequest()
.bodyValue(Map.of("successCount", 0, "failCount", 0));
}
return Flux.fromIterable(names)
.flatMap(name ->
client.fetch(AiCommentReply.class, name)
.flatMap(record -> findReplyForRecord(record)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setPublished(true);
return client.update(latest);
})))
.thenReturn(true)
)
.onErrorResume(e -> {
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
return Mono.just(false);
})
.defaultIfEmpty(false)
)
.collectList()
.flatMap(results -> {
long successCount = results.stream().filter(b -> b).count();
long failCount = results.size() - successCount;
return ServerResponse.ok()
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
});
});
}
private Mono<ServerResponse> batchRejectReplies(ServerRequest request) {
return request.bodyToMono(String.class)
.flatMap(body -> {
List<String> names;
try {
JsonNode node = objectMapper.readTree(body);
names = new ArrayList<>();
node.get("names").forEach(n -> names.add(n.asText()));
} catch (Exception e) {
return ServerResponse.badRequest()
.bodyValue(Map.of("successCount", 0, "failCount", 0));
}
return Flux.fromIterable(names)
.flatMap(name ->
client.fetch(AiCommentReply.class, name)
.flatMap(record -> findReplyForRecord(record)
.flatMap(reply -> client.delete(reply))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
return client.update(latest);
})))
.thenReturn(true)
)
.onErrorResume(e -> {
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
return Mono.just(false);
})
.defaultIfEmpty(false)
)
.collectList()
.flatMap(results -> {
long successCount = results.stream().filter(b -> b).count();
long failCount = results.size() - successCount;
return ServerResponse.ok()
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
});
});
}
private Mono<ServerResponse> batchDeleteReplies(ServerRequest request) {
return request.bodyToMono(String.class)
.flatMap(body -> {
List<String> names;
try {
JsonNode node = objectMapper.readTree(body);
names = new ArrayList<>();
node.get("names").forEach(n -> names.add(n.asText()));
} catch (Exception e) {
return ServerResponse.badRequest()
.bodyValue(Map.of("successCount", 0, "failCount", 0));
}
return Flux.fromIterable(names)
.flatMap(name ->
client.fetch(AiCommentReply.class, name)
.flatMap(record -> client.delete(record)
.thenReturn(true)
)
.onErrorResume(e -> {
log.warn("Batch delete failed for {}: {}", name, e.getMessage());
return Mono.just(false);
})
.defaultIfEmpty(false)
)
.collectList()
.flatMap(results -> {
long successCount = results.stream().filter(b -> b).count();
long failCount = results.size() - successCount;
return ServerResponse.ok()
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
});
});
}
private Mono<ServerResponse> triggerReply(ServerRequest request) {
var commentName = request.pathVariable("commentName");
// Check if there's already an AiCommentReply record for this comment
return client.list(AiCommentReply.class,
record -> commentName.equals(record.getSpec().getCommentId())
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.hasElements()
.flatMap(hasExisting -> {
if (hasExisting) {
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回复")));
});
}
private Mono<ServerResponse> triggerConversationReply(ServerRequest request) {
var replyName = request.pathVariable("replyName");
// First fetch the reply to get its parent comment name
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
var commentName = reply.getSpec().getCommentName();
// Check if there's already an AiCommentReply record for this conversation
return client.list(AiCommentReply.class,
record -> replyName.equals(record.getSpec().getReplyTo())
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.hasElements()
.flatMap(hasExisting -> {
if (hasExisting) {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
}
return orchestrator.processComment(commentName, replyName, true)
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")));
});
})
.switchIfEmpty(ServerResponse.notFound().build());
}
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
// Find the Reply that belongs to the same comment and was created by AI
return client.list(Reply.class,
reply -> {
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
return false;
}
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"));
},
null)
.next()
.switchIfEmpty(Mono.empty());
}
public record ConversationMessage(
String type,
String owner,
String content,
String time,
boolean isAi
) {}
}
@@ -0,0 +1,60 @@
package top.nxxy335.commentaiautopilot.extension;
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 = "AiCommentReply",
plural = "aicommentreplies",
singular = "aicommentreply"
)
public class AiCommentReply extends AbstractExtension {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Spec spec;
@Data
@Schema(name = "AiCommentReplySpec")
public static class Spec {
@Schema(description = "关联评论ID")
private String commentId;
@Schema(description = "关联文章ID")
private String postId;
@Schema(description = "关联文章Slug,用于生成文章链接")
private String postSlug;
@Schema(description = "AI回复内容")
private String reply;
@Schema(description = "审核评分")
private Integer score;
@Schema(description = "状态: PENDING/REVIEWING/PASS/FAIL")
private String status;
@Schema(description = "重试次数")
private Integer retryCount;
@Schema(description = "回复目标的评论ID")
private String replyTo;
@Schema(description = "是否为AI对话中的回复")
private Boolean isAiConversation;
@Schema(description = "是否已发布回复")
private Boolean published;
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
private String sentiment;
}
}
@@ -0,0 +1,155 @@
package top.nxxy335.commentaiautopilot.listener;
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.extension.ExtensionClient;
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.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
@Component
@Slf4j
@RequiredArgsConstructor
public class CommentReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
private final AiReplyOrchestrator orchestrator;
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-";
// Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now();
// In-memory dedup lock: prevents the same comment from being processed multiple times
// even if reconcile is triggered concurrently
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
@Override
public Result reconcile(Request request) {
var name = request.name();
// Acquire lock at the very beginning to prevent any concurrent processing
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
return Result.doNotRetry();
}
AtomicBoolean asyncStarted = new AtomicBoolean(false);
try {
client.fetch(Comment.class, name).ifPresent(comment -> {
if (isProcessed(comment.getMetadata().getAnnotations())) {
return;
}
// Skip comments created before plugin startup (historical comments)
var creationTime = comment.getMetadata().getCreationTimestamp();
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name);
markProcessed(comment);
client.update(comment);
return;
}
// Skip comments from AI persona itself
if (isAiComment(comment)) {
markProcessed(comment);
client.update(comment);
return;
}
// Dedup: check if we already have an AiCommentReply record for this comment
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
record -> name.equals(record.getSpec().getCommentId())
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.isEmpty();
if (alreadyHasRecord) {
log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name);
markProcessed(comment);
client.update(comment);
return;
}
// Mark as processed first to avoid re-processing
markProcessed(comment);
client.update(comment);
// Top-level comment → always trigger AI reply
log.info("[CommentReconciler] New top-level comment detected: {}", name);
asyncStarted.set(true);
orchestrator.processComment(name, null, false)
.subscribeOn(Schedulers.boundedElastic())
.doFinally(signal -> {
processingLocks.remove(name);
log.debug("[CommentReconciler] Released processing lock for: {}", name);
})
.subscribe(
null,
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
);
});
} catch (Exception e) {
log.error("[CommentReconciler] Error in reconcile for {}: {}", name, e.getMessage(), e);
} finally {
// Only release lock here if async processing was NOT started
// (async path releases lock in doFinally)
if (!asyncStarted.get()) {
processingLocks.remove(name);
}
}
return Result.doNotRetry();
}
/**
* Check if a comment is from AI persona.
*/
private boolean isAiComment(Comment comment) {
var owner = comment.getSpec().getOwner();
if (owner != null && owner.getName() != null
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
return true;
}
if (owner != null && owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
return true;
}
return false;
}
private boolean isProcessed(Map<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
private void markProcessed(Comment comment) {
var annotations = comment.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
comment.getMetadata().setAnnotations(annotations);
}
annotations.put(PROCESSED_ANNOTATION, "true");
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Comment())
.syncAllOnStart(false)
.build();
}
}
@@ -0,0 +1,165 @@
package top.nxxy335.commentaiautopilot.listener;
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.Reply;
import run.halo.app.extension.ExtensionClient;
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.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Component
@Slf4j
@RequiredArgsConstructor
public class ReplyReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
private final AiReplyOrchestrator orchestrator;
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";
// Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now();
@Override
public Result reconcile(Request request) {
var name = request.name();
client.fetch(Reply.class, name).ifPresent(reply -> {
if (isProcessed(reply.getMetadata().getAnnotations())) {
return;
}
// Skip replies created before plugin startup (historical replies)
var creationTime = reply.getMetadata().getCreationTimestamp();
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
log.debug("[ReplyReconciler] Skipping historical reply: {} (created before plugin startup)", name);
markProcessed(reply);
client.update(reply);
return;
}
// Skip replies from AI persona itself
var owner = reply.getSpec().getOwner();
if (owner != null && owner.getName() != null
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
markProcessed(reply);
client.update(reply);
return;
}
// Also skip if owner has AI marker annotation
if (owner != null && owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
markProcessed(reply);
client.update(reply);
return;
}
String parentCommentName = reply.getSpec().getCommentName();
if (parentCommentName == null || parentCommentName.isBlank()) {
return;
}
// Check if this reply is specifically replying to an AI reply
// by checking the quoteReply field
String quoteReply = reply.getSpec().getQuoteReply();
if (quoteReply == null || quoteReply.isBlank()) {
// No quoteReply - this is a direct reply to the top-level comment,
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
return;
}
// This reply quotes another reply - check if the quoted reply is from AI
boolean isReplyToAi = isAiReply(quoteReply);
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
if (!isReplyToAi) {
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
return;
}
// Dedup: check if we already have an AiCommentReply record for this reply
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
record -> name.equals(record.getSpec().getReplyTo())
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.isEmpty();
if (alreadyHasRecord) {
log.debug("[ReplyReconciler] Already have AiCommentReply record for reply: {}, skipping", name);
markProcessed(reply);
client.update(reply);
return;
}
// Mark as processed
markProcessed(reply);
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)
.subscribeOn(Schedulers.boundedElastic())
.subscribe(
null,
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
);
});
return Result.doNotRetry();
}
/**
* Check if a specific Reply is from AI persona.
*/
private boolean isAiReply(String replyName) {
return client.fetch(Reply.class, replyName)
.map(reply -> {
var owner = reply.getSpec().getOwner();
if (owner != null && owner.getName() != null
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
return true;
}
if (owner != null && owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
return true;
}
return false;
})
.orElse(false);
}
private boolean isProcessed(Map<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
private void markProcessed(Reply reply) {
var annotations = reply.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
reply.getMetadata().setAnnotations(annotations);
}
annotations.put(PROCESSED_ANNOTATION, "true");
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Reply())
.syncAllOnStart(false)
.build();
}
}
@@ -0,0 +1,28 @@
package top.nxxy335.commentaiautopilot.processor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.ITemplateContext;
import org.thymeleaf.model.IModel;
import org.thymeleaf.processor.element.IElementModelStructureHandler;
import reactor.core.publisher.Mono;
import run.halo.app.theme.dialect.TemplateHeadProcessor;
/**
* Template head processor for comment-ai-autopilot.
* Previously used to inject avatar replacement scripts, but now
* avatar is handled natively via Gravatar (email-based).
* Kept as a no-op to avoid breaking the Spring component scan.
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class AiBadgeHeadProcessor implements TemplateHeadProcessor {
@Override
public Mono<Void> process(ITemplateContext context, IModel model,
IElementModelStructureHandler structureHandler) {
return Mono.empty();
}
}
@@ -0,0 +1,80 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.aifoundation.AiModelService;
import run.halo.aifoundation.chat.LanguageModel;
import run.halo.aifoundation.chat.GenerateTextResult;
/**
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
*/
@Slf4j
@RequiredArgsConstructor
public class AiFoundationClient {
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
private final ExtensionGetter extensionGetter;
private final ReactiveExtensionClient client;
/**
* Call AI Foundation to generate a chat response using the specified model.
* Checks at runtime whether the ai-foundation plugin is installed and enabled
* before attempting to use it.
*
* @param prompt the prompt text
* @param modelName the AiModel metadata.name, null or blank to use default model
* @return the generated text, or empty if AI Foundation is unavailable
*/
public Mono<String> chat(String prompt, String modelName) {
return isAiFoundationEnabled()
.flatMap(enabled -> {
if (!enabled) {
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply");
return Mono.empty();
}
return doChat(prompt, modelName);
});
}
/**
* Check if the ai-foundation plugin is installed and enabled at runtime.
*/
private Mono<Boolean> isAiFoundationEnabled() {
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
.map(plugin -> plugin.getSpec().getEnabled())
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
return Mono.just(false);
});
}
private Mono<String> doChat(String prompt, String modelName) {
return extensionGetter.getEnabledExtension(AiModelService.class)
.flatMap(service -> {
Mono<LanguageModel> modelMono;
if (modelName != null && !modelName.isBlank()) {
modelMono = service.languageModel(modelName);
} else {
modelMono = service.languageModel();
}
return modelMono.flatMap(model -> model.generateText(prompt)
.map(GenerateTextResult::getText)
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
text.length(), modelName != null ? modelName : "default"))
);
})
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
}
@@ -0,0 +1,23 @@
package top.nxxy335.commentaiautopilot.service;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
/**
* Configuration that registers AiFoundationClient only when
* AI Foundation plugin classes are available in the classloader.
* When AI Foundation is not installed, this entire configuration is skipped.
*/
@Configuration
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
public class AiFoundationConfiguration {
@Bean
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
ReactiveExtensionClient client) {
return new AiFoundationClient(extensionGetter, client);
}
}
@@ -0,0 +1,284 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.ReactiveSettingFetcher;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
@RequiredArgsConstructor
public class AiReplyOrchestrator {
private final ContextExtractor contextExtractor;
private final PromptBuilder promptBuilder;
private final AiReplyService aiReplyService;
private final SentimentService sentimentService;
private final ReviewService reviewService;
private final CommentReplyPublisher commentReplyPublisher;
private final FilterService filterService;
private final ReactiveExtensionClient client;
private final ReactiveSettingFetcher settingFetcher;
// In-memory dedup: tracks which comment/reply is currently being processed
// Prevents duplicate replies when Reconciler fires multiple times
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
/**
* Process a new comment or reply.
*
* @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)
*/
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation) {
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
// In-memory dedup: if already processing, skip immediately
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
return Mono.empty();
}
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}",
commentName, replyName, isAiConversation);
return isAutoReplyEnabled()
.flatMap(enabled -> {
if (!enabled) {
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 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) {
return hasExistingReply(commentName)
.flatMap(hasReply -> {
if (hasReply) {
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
return Mono.empty();
}
return doProcess(commentName, replyName, isAiConversation);
});
}
return hasExistingConversationReply(replyName)
.flatMap(hasReply -> {
if (hasReply) {
log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
return Mono.empty();
}
return doProcess(commentName, replyName, isAiConversation);
});
});
})
.doOnError(e -> log.error("[Orchestrator] Error processing comment {}: {}", commentName, e.getMessage(), e))
.doFinally(signal -> {
// Always release the lock when processing completes
processingLocks.remove(lockKey);
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
})
.then();
}
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation) {
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))
);
})
)
);
}
/**
* Check if there's already ANY AiCommentReply record for this top-level comment.
* Checks for ANY record (not just published) to prevent race conditions.
*/
private Mono<Boolean> hasExistingReply(String commentName) {
return client.list(AiCommentReply.class,
record -> commentName.equals(record.getSpec().getCommentId())
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.hasElements()
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.debug("[Orchestrator] Failed to check existing replies: {}", e.getMessage());
return Mono.just(false);
});
}
/**
* Check if we already have ANY AiCommentReply record for this specific reply (conversation).
* Checks for ANY record (not just published) to prevent race conditions.
*/
private Mono<Boolean> hasExistingConversationReply(String replyName) {
if (replyName == null || replyName.isBlank()) {
return Mono.just(false);
}
return client.list(AiCommentReply.class,
record -> replyName.equals(record.getSpec().getReplyTo())
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
null)
.hasElements()
.defaultIfEmpty(false)
.onErrorResume(e -> {
log.debug("[Orchestrator] Failed to check existing conversation replies: {}", e.getMessage());
return Mono.just(false);
});
}
/**
* Generate AI reply, optionally review it, then publish.
*/
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
AiCommentReply replyRecord, String modelName) {
return aiReplyService.generateReply(prompt, modelName)
.defaultIfEmpty("")
.flatMap(aiReply -> {
if (aiReply.isBlank()) {
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
}
log.info("[Orchestrator] AI generated reply for {}: {} chars",
context.commentId(), aiReply.length());
return reviewService.review(context.postContent(), context.commentContent(), aiReply, modelName)
.flatMap(reviewResult -> {
log.info("[Orchestrator] Review for {}: score={}, status={}, reason={}",
context.commentId(), reviewResult.score(), reviewResult.status(), reviewResult.reason());
if ("FAIL".equals(reviewResult.status())) {
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
context.commentId());
return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then();
}
return publishReply(context, aiReply, replyRecord, reviewResult.score());
})
.switchIfEmpty(
publishReply(context, aiReply, replyRecord, 100)
)
.onErrorResume(e -> {
log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage());
return publishReply(context, aiReply, replyRecord, 100);
});
});
}
/**
* Publish the reply and update the record to PASS + published=true.
*/
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
AiCommentReply replyRecord, int score) {
return isAutoPublishEnabled()
.flatMap(autoPublish -> {
return commentReplyPublisher.publishReply(
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish)
.flatMap(publishedReply -> {
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
})
.flatMap(updated -> Mono.empty());
})
.then();
}
private Mono<String> getModelName() {
return settingFetcher.getSettingValue("model")
.map(node -> {
var nameNode = node.get("modelName");
if (nameNode != null && !nameNode.asText().isBlank()) {
return nameNode.asText();
}
return "";
})
.onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage());
return Mono.just("");
})
.defaultIfEmpty("");
}
private Mono<Boolean> isAutoReplyEnabled() {
return settingFetcher.getSettingValue("basic")
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true))
.onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage());
return Mono.just(true);
})
.defaultIfEmpty(true);
}
private Mono<Boolean> isAutoPublishEnabled() {
return settingFetcher.getSettingValue("basic")
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true))
.onErrorResume(e -> {
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage());
return Mono.just(true);
})
.defaultIfEmpty(true);
}
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
AiCommentReply record = new AiCommentReply();
record.setMetadata(new Metadata());
record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8));
record.setSpec(new AiCommentReply.Spec());
record.getSpec().setCommentId(context.commentId());
record.getSpec().setPostId(context.postId());
record.getSpec().setPostSlug(context.postSlug());
record.getSpec().setReply("");
record.getSpec().setScore(0);
record.getSpec().setStatus("PENDING");
record.getSpec().setRetryCount(0);
record.getSpec().setReplyTo(context.replyTo());
record.getSpec().setIsAiConversation(context.isAiConversation());
record.getSpec().setPublished(false);
record.getSpec().setSentiment(sentiment);
return client.create(record)
.doOnSuccess(created -> log.info("[Orchestrator] Created AiCommentReply record: {}",
created.getMetadata().getName()));
}
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
int score, String status, boolean published) {
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
record.getMetadata().getName(), status, score, published);
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
.flatMap(latest -> {
latest.getSpec().setReply(reply);
latest.getSpec().setScore(score);
latest.getSpec().setStatus(status);
latest.getSpec().setPublished(published);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException)
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
record.getMetadata().getName()))
)
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
record.getMetadata().getName(), status, score, published));
}
}
@@ -0,0 +1,37 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class AiReplyService {
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
}
/**
* Generate an AI reply using the AI Foundation plugin.
*
* @param prompt the prompt text
* @param modelName the model name (null for default)
*/
public Mono<String> generateReply(String prompt, String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("AI Foundation plugin is not installed, cannot generate reply");
return Mono.empty();
}
return client.chat(prompt, modelName)
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
.onErrorResume(e -> {
log.warn("AI Foundation not available: {}", e.getMessage());
return Mono.empty();
});
}
}
@@ -0,0 +1,210 @@
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 java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Component
@Slf4j
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.
*/
public Mono<Reply> publishReply(String parentCommentName, String replyContent,
String postName, String quoteReplyName, boolean autoPublish) {
return checkExistingAiReply(parentCommentName, quoteReplyName)
.flatMap(exists -> {
if (exists) {
log.info("[Publisher] AI reply already exists for comment: {}, skipping duplicate publish",
parentCommentName);
return Mono.empty();
}
return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish);
});
}
private Mono<Boolean> checkExistingAiReply(String parentCommentName, String quoteReplyName) {
return client.list(Reply.class,
reply -> {
if (!parentCommentName.equals(reply.getSpec().getCommentName())) {
return false;
}
var owner = reply.getSpec().getOwner();
if (owner == null || owner.getName() == null) {
return false;
}
boolean isAiOwner = owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX);
boolean hasAiAnnotation = owner.getAnnotations() != null
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
boolean isAiReply = isAiOwner || hasAiAnnotation;
if (!isAiReply) {
return false;
}
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
return quoteReplyName.equals(reply.getSpec().getQuoteReply());
}
return true;
},
null)
.hasElements()
.defaultIfEmpty(false);
}
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());
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);
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
spec.setQuoteReply(quoteReplyName);
}
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");
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: {}",
personaName, 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.
*/
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);
}
/**
* 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 String generateReplyName() {
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
}
/**
* Generate Gravatar URL from email address using SHA-256 hash.
*/
private String generateGravatarUrl(String email) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
return "https://cn.cravatar.com/avatar/" + hexString;
} catch (Exception e) {
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
return "";
}
}
}
@@ -0,0 +1,211 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostContentService;
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.ReactiveExtensionClient;
@Component
@Slf4j
@RequiredArgsConstructor
public class ContextExtractor {
private final ReactiveExtensionClient client;
private final PostContentService postContentService;
/**
* Extract context from a comment event.
* Returns a CommentContext record with all needed info.
*
* @param commentName the Comment name (always required)
* @param replyName the Reply name that triggered this (null for top-level comments)
* @param isAiConversation whether this is a continuation of AI conversation
*/
public Mono<CommentContext> extract(String commentName, String replyName, boolean isAiConversation) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
if (replyName != null && !replyName.isBlank()) {
// This is a reply to a comment - fetch the Reply for content
return client.fetch(Reply.class, replyName)
.flatMap(reply -> buildContextFromReply(comment, reply, isAiConversation))
.switchIfEmpty(buildContext(comment, isAiConversation));
}
return buildContext(comment, isAiConversation);
});
}
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
var commentContent = extractCommentContent(comment);
var commentOwner = extractCommentOwner(comment);
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
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
))
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
postName,
"",
commentContent,
commentOwner,
"",
"",
null,
isAiConversation
));
}
return Mono.just(new CommentContext(
comment.getMetadata().getName(),
"",
"",
commentContent,
commentOwner,
"",
"",
null,
isAiConversation
));
}
private Mono<CommentContext> buildContextFromReply(Comment comment, Reply reply, boolean isAiConversation) {
var replyContent = extractReplyContent(reply);
var replyOwner = extractReplyOwner(reply);
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
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
))
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
postName,
"",
replyContent,
replyOwner,
"",
"",
reply.getMetadata().getName(),
isAiConversation
));
}
return Mono.just(new CommentContext(
comment.getMetadata().getName(),
"",
"",
replyContent,
replyOwner,
"",
"",
reply.getMetadata().getName(),
isAiConversation
));
}
private String extractCommentContent(Comment comment) {
var spec = comment.getSpec();
// Prefer raw content (plain text / markdown), fall back to rendered HTML
String raw = spec.getRaw();
if (raw != null && !raw.isBlank()) {
return raw;
}
String content = spec.getContent();
if (content != null && !content.isBlank()) {
return Jsoup.clean(content, Safelist.none());
}
return "";
}
private String extractCommentOwner(Comment comment) {
var owner = comment.getSpec().getOwner();
if (owner != null) {
String displayName = owner.getDisplayName();
if (displayName != null && !displayName.isBlank()) {
return displayName;
}
}
return "匿名用户";
}
private String extractReplyContent(Reply reply) {
var spec = reply.getSpec();
String raw = spec.getRaw();
if (raw != null && !raw.isBlank()) {
return raw;
}
String content = spec.getContent();
if (content != null && !content.isBlank()) {
return Jsoup.clean(content, Safelist.none());
}
return "";
}
private String extractReplyOwner(Reply reply) {
var owner = reply.getSpec().getOwner();
if (owner != null) {
String displayName = owner.getDisplayName();
if (displayName != null && !displayName.isBlank()) {
return displayName;
}
}
return "匿名用户";
}
private Mono<String> getPostContent(String postName) {
return postContentService.getReleaseContent(postName)
.map(ContentWrapper::getContent)
.map(html -> {
if (html != null && !html.isBlank()) {
return Jsoup.clean(html, Safelist.none());
}
return "";
})
.defaultIfEmpty("");
}
public record CommentContext(
String commentId,
String postId,
String postSlug,
String commentContent,
String commentOwner,
String postTitle,
String postContent,
String replyTo,
boolean isAiConversation
) {}
}
@@ -0,0 +1,145 @@
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.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Component
@Slf4j
public class FilterService {
private final ReactiveExtensionClient client;
private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
private static final String GROUP_CONTENT = "content.halo.run";
public FilterService(ReactiveExtensionClient client) {
this.client = client;
this.objectMapper = new ObjectMapper();
}
public Mono<Boolean> shouldProcess(Comment comment) {
return checkBlockedCommenters(comment)
.flatMap(blocked -> {
if (blocked) {
return Mono.just(false);
}
return checkAnnotationEnabled(comment);
})
.defaultIfEmpty(true)
.onErrorResume(e -> {
log.warn("[Filter] Error checking filter rules: {}", e.getMessage());
return Mono.just(true);
});
}
public Mono<Boolean> shouldProcess(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(this::shouldProcess)
.defaultIfEmpty(true)
.onErrorResume(e -> {
log.warn("[Filter] Error fetching comment for filter check: {}", e.getMessage());
return Mono.just(true);
});
}
private Mono<Boolean> checkBlockedCommenters(Comment comment) {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> {
var data = cm.getData();
if (data == null) return false;
String basicJson = data.get("basic");
if (basicJson == null || basicJson.isBlank()) return false;
try {
JsonNode node = objectMapper.readTree(basicJson);
String blockedCommentersStr = node.has("blockedCommenters")
? node.get("blockedCommenters").asText("") : "";
List<String> blockedCommenters = parseList(blockedCommentersStr);
String commenterName = getCommenterDisplayName(comment);
if (isInList(commenterName, blockedCommenters)) {
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName);
return true;
}
return false;
} catch (Exception e) {
log.warn("[Filter] Failed to parse basic config: {}", e.getMessage());
return false;
}
})
.defaultIfEmpty(false);
}
private Mono<Boolean> checkAnnotationEnabled(Comment comment) {
if (comment.getSpec() == null || comment.getSpec().getSubjectRef() == null) {
return Mono.just(true);
}
var subjectRef = comment.getSpec().getSubjectRef();
String group = subjectRef.getGroup();
String kind = subjectRef.getKind();
String name = subjectRef.getName();
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
return client.fetch(Post.class, name)
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true))
.defaultIfEmpty(true);
}
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
return client.fetch(SinglePage.class, name)
.map(page -> resolveAnnotation(page.getMetadata().getAnnotations(), false))
.defaultIfEmpty(false);
}
// Unknown subjectRef type, default to allowing
return Mono.just(true);
}
private boolean resolveAnnotation(java.util.Map<String, String> annotations, boolean defaultEnabled) {
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
return defaultEnabled;
}
String value = annotations.get(ANNOTATION_KEY);
if ("false".equalsIgnoreCase(value)) {
log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
return false;
}
if ("true".equalsIgnoreCase(value)) {
return true;
}
// Unrecognized value, fall back to default
return defaultEnabled;
}
private String getCommenterDisplayName(Comment comment) {
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
var displayName = comment.getSpec().getOwner().getDisplayName();
return displayName != null ? displayName : "";
}
private List<String> parseList(String str) {
if (str == null || str.isBlank()) return Collections.emptyList();
return Arrays.stream(str.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
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));
}
}
@@ -0,0 +1,109 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.plugin.ReactiveSettingFetcher;
@Component
@Slf4j
@RequiredArgsConstructor
public class PromptBuilder {
private final ReactiveSettingFetcher settingFetcher;
private static final String SAFETY_PROMPT = """
【安全规范】
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。"
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
""";
private static final String DEFAULT_PROMPT_TEMPLATE = """
{{persona_prompt}}
{{safety_prompt}}
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
请回复以下评论。注意:
- 回复长度应与评论长度匹配,简短问候简短回复
- 不要复述或总结文章内容
- 自然对话,不要写小作文
- 只有评论涉及具体内容时才针对性回应
文章(仅供理解上下文,不要复述):
{{article}}
评论:
{{comment}}
""";
private static final String DEFAULT_PERSONA_PROMPT = """
你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
""";
public Mono<String> buildPrompt(ContextExtractor.CommentContext context) {
return Mono.zip(getPromptTemplate(), getPersonaPrompt())
.map(tuple -> {
String template = tuple.getT1();
String personaPrompt = tuple.getT2();
String prompt = template
.replace("{{persona_prompt}}", personaPrompt)
.replace("{{safety_prompt}}", SAFETY_PROMPT)
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
return prompt;
});
}
public Mono<String> buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
return buildPrompt(context)
.map(prompt -> {
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
return prompt;
}
String sentimentHint = switch (sentiment) {
case "POSITIVE" -> "\n\n【情感提示】评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。";
case "NEGATIVE" -> "\n\n【情感提示】评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。";
default -> "";
};
return prompt + sentimentHint;
});
}
private Mono<String> getPromptTemplate() {
return settingFetcher.getSettingValue("prompt")
.map(node -> {
var templateNode = node.get("customPromptTemplate");
if (templateNode != null && !templateNode.asText().isBlank()) {
return templateNode.asText();
}
return DEFAULT_PROMPT_TEMPLATE;
})
.onErrorResume(e -> {
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
return Mono.just(DEFAULT_PROMPT_TEMPLATE);
})
.defaultIfEmpty(DEFAULT_PROMPT_TEMPLATE);
}
private Mono<String> getPersonaPrompt() {
return settingFetcher.getSettingValue("persona")
.map(node -> {
var promptNode = node.get("personaPrompt");
if (promptNode != null && !promptNode.asText().isBlank()) {
return promptNode.asText();
}
return DEFAULT_PERSONA_PROMPT;
})
.onErrorResume(e -> {
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
return Mono.just(DEFAULT_PERSONA_PROMPT);
})
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
}
}
@@ -0,0 +1,88 @@
package top.nxxy335.commentaiautopilot.service;
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) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
this.settingFetcher = settingFetcher;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复:
文章:
%s
原始评论:
%s
生成的回复:
%s
请检查以下内容:
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
2. 是否包含不适宜公开传播的内容?
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
请直接回答"安全""不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
""";
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
}
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
truncate(articleContent, 2000),
truncate(commentContent, 500),
truncate(aiReply, 500));
return client.chat(reviewPrompt, modelName)
.map(this::parseSafetyResult)
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
.onErrorResume(e -> {
log.warn("Review failed, auto-passing: {}", e.getMessage());
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
});
}
private ReviewResult parseSafetyResult(String response) {
if (response == null || response.isBlank()) {
return new ReviewResult(100, "PASS", "审核无响应,自动通过");
}
String trimmed = response.trim().toLowerCase();
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) {
log.warn("AI Review: content is UNSAFE, response: {}", response);
return new ReviewResult(0, "FAIL", "内容安全审核不通过");
}
if (trimmed.contains("安全") || trimmed.contains("safe")) {
log.info("AI Review: content is SAFE");
return new ReviewResult(100, "PASS", "内容安全审核通过");
}
// If unclear response, default to pass
log.warn("AI Review: unclear response, auto-passing: {}", response);
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过");
}
private String truncate(String text, int maxLength) {
if (text == null) return "";
return text.length() > maxLength ? text.substring(0, maxLength) : text;
}
public record ReviewResult(int score, String status, String reason) {}
}
@@ -0,0 +1,56 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class SentimentService {
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
}
public record SentimentResult(String sentiment, double confidence) {
public static final String POSITIVE = "POSITIVE";
public static final String NEUTRAL = "NEUTRAL";
public static final String NEGATIVE = "NEGATIVE";
}
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
if (client == null) {
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
}
String prompt = buildSentimentPrompt(commentContent);
return client.chat(prompt, modelName)
.map(response -> {
String sentiment = parseSentiment(response);
return new SentimentResult(sentiment, 1.0);
})
.onErrorResume(e -> {
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
})
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
}
private String buildSentimentPrompt(String commentContent) {
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
}
private String parseSentiment(String response) {
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
String upper = response.trim().toUpperCase();
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
return SentimentResult.NEUTRAL;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,29 @@
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: comment-ai-autopilot-post-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: Post
formSchema:
- $formkit: switch
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
label: 启用AI回评
value: true
help: 开启后,该文章收到评论时将自动触发AI回复
---
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: comment-ai-autopilot-single-page-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: SinglePage
formSchema:
- $formkit: switch
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
label: 启用AI回评
value: false
help: 开启后,该页面收到评论时将自动触发AI回复
@@ -0,0 +1,18 @@
apiVersion: v1alpha1
kind: Role
metadata:
name: comment-ai-autopilot-role-manage
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Comment AI Autopilot Management"
rbac.authorization.halo.run/display-name: "AI回评管理"
rbac.authorization.halo.run/ui-permissions: |
["plugin:comment-ai-autopilot:manage"]
rules:
- apiGroups: ["comment-ai-autopilot.nxxy335.top"]
resources: ["comment-ai-autopilot/aicommentreplies"]
verbs: ["*"]
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
resources: ["*"]
verbs: ["*"]
@@ -0,0 +1,60 @@
apiVersion: v1alpha1
kind: Setting
metadata:
name: comment-ai-autopilot-settings
spec:
forms:
- group: basic
label: 基本设置
formSchema:
- $formkit: switch
name: autoReply
label: 自动回复
value: true
- $formkit: switch
name: autoPublish
label: 自动发布
value: true
- $formkit: number
name: maxRetryCount
label: 最大重试次数
value: 3
min: 1
max: 10
- $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头像服务展示头像
value: ""
- group: model
label: 模型设置
formSchema:
- $formkit: text
name: modelName
label: AI模型名称
help: 留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型
value: ""
- group: prompt
label: Prompt设置
formSchema:
- $formkit: textarea
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}}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+27
View File
@@ -0,0 +1,27 @@
# Refer https://docs.halo.run/developer-guide/plugin/basics/manifest
apiVersion: plugin.halo.run/v1alpha1
kind: Plugin
metadata:
# The name defines how the plugin is invoked, A unique name
name: comment-ai-autopilot
spec:
enabled: true
requires: ">=2.23.0"
author:
name: 暖心向阳335
website: https://github.com/暖心向阳335
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
displayName: "AI回评"
description: "基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复"
license:
- name: "GPL-3.0"
url: "https://github.com/暖心向阳335/comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap"
version: "0.0.1-w5s2t7"
pluginDependencies:
ai-foundation?: "*"
@@ -0,0 +1,28 @@
package top.nxxy335.commentaiautopilot;
import org.junit.jupiter.api.Test;
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.SchemeManager;
import run.halo.app.plugin.PluginContext;
@ExtendWith(MockitoExtension.class)
class CommentAiAutopilotPluginTest {
@Mock
PluginContext context;
@Mock
SchemeManager schemeManager;
@InjectMocks
CommentAiAutopilotPlugin plugin;
@Test
void contextLoads() {
plugin.start();
plugin.stop();
}
}