refactor: 全面优化完善 - ObjectMapper注入、索引优化、代码去重、Bug修复
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group 'top.nxxy335.commentaiautopilot'
|
group 'top.nxxy335.commentaiautopilot'
|
||||||
version '1.0.0-beta.2'
|
version '1.0.0-beta.3'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
|||||||
.indexFunc(ext -> ext.getSpec().getPostId()));
|
.indexFunc(ext -> ext.getSpec().getPostId()));
|
||||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
||||||
.indexFunc(ext -> ext.getSpec().getStatus()));
|
.indexFunc(ext -> ext.getSpec().getStatus()));
|
||||||
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.sentiment", String.class)
|
||||||
|
.indexFunc(ext -> ext.getSpec().getSentiment()));
|
||||||
|
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.published", String.class)
|
||||||
|
.indexFunc(ext -> String.valueOf(ext.getSpec().getPublished())));
|
||||||
});
|
});
|
||||||
schemeManager.register(AiPersona.class);
|
schemeManager.register(AiPersona.class);
|
||||||
|
|
||||||
|
|||||||
+35
-76
@@ -8,7 +8,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import run.halo.app.core.extension.content.Comment;
|
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.content.Reply;
|
||||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
@@ -16,6 +15,7 @@ import run.halo.app.extension.Metadata;
|
|||||||
import run.halo.app.extension.GroupVersion;
|
import run.halo.app.extension.GroupVersion;
|
||||||
import run.halo.app.extension.ListOptions;
|
import run.halo.app.extension.ListOptions;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
|
import run.halo.app.extension.index.query.Queries;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.PageRequestImpl;
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
@@ -24,6 +24,8 @@ import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
|
|||||||
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -56,16 +58,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
private final AiFoundationClient aiFoundationClient;
|
private final AiFoundationClient aiFoundationClient;
|
||||||
private final CommentReplyPublisher commentReplyPublisher;
|
private final CommentReplyPublisher commentReplyPublisher;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) {
|
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher, ObjectMapper objectMapper, PersonaResolver personaResolver) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
this.cleanupService = cleanupService;
|
this.cleanupService = cleanupService;
|
||||||
this.aiFoundationClient = aiFoundationClient;
|
this.aiFoundationClient = aiFoundationClient;
|
||||||
this.commentReplyPublisher = commentReplyPublisher;
|
this.commentReplyPublisher = commentReplyPublisher;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
|
this.personaResolver = personaResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -133,19 +137,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
final Instant finalStartInstant = startInstant;
|
final Instant finalStartInstant = startInstant;
|
||||||
final Instant finalEndInstant = endInstant;
|
final Instant finalEndInstant = endInstant;
|
||||||
|
|
||||||
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
|
// Check if we need in-memory filtering (keyword or date range)
|
||||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|
||||||
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
|
|
||||||
|
// Build server-side query for status and sentiment (indexed fields)
|
||||||
|
var listOptionsBuilder = ListOptions.builder();
|
||||||
|
if (!statusFilter.isBlank()) {
|
||||||
|
listOptionsBuilder.andQuery(Queries.equal("spec.status", statusFilter));
|
||||||
|
}
|
||||||
|
if (!sentimentFilter.isBlank()) {
|
||||||
|
listOptionsBuilder.andQuery(Queries.equal("spec.sentiment", sentimentFilter));
|
||||||
|
}
|
||||||
|
var listOptions = listOptionsBuilder.build();
|
||||||
|
|
||||||
if (needsMemoryFilter) {
|
if (needsMemoryFilter) {
|
||||||
// Fall back to listAll + in-memory filter for complex queries
|
// Fall back to listAll + in-memory filter for keyword/date queries
|
||||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, listOptions, Sort.unsorted())
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(replies -> {
|
.map(replies -> {
|
||||||
var filtered = replies.stream()
|
var filtered = replies.stream()
|
||||||
.filter(r -> {
|
.filter(r -> {
|
||||||
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
|
||||||
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
|
||||||
if (!keywordFilter.isBlank()) {
|
if (!keywordFilter.isBlank()) {
|
||||||
String reply = r.getSpec().getReply();
|
String reply = r.getSpec().getReply();
|
||||||
if (reply == null || !reply.contains(keywordFilter)) return false;
|
if (reply == null || !reply.contains(keywordFilter)) return false;
|
||||||
@@ -184,13 +195,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No filters - use server-side pagination directly
|
// No memory filters needed - use server-side pagination directly
|
||||||
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
Sort sort = "asc".equalsIgnoreCase(sortOrder)
|
||||||
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||||
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||||
|
|
||||||
var listOptions = ListOptions.builder().build();
|
|
||||||
|
|
||||||
return client.listBy(AiCommentReply.class, listOptions,
|
return client.listBy(AiCommentReply.class, listOptions,
|
||||||
PageRequestImpl.of(page - 1, size, sort))
|
PageRequestImpl.of(page - 1, size, sort))
|
||||||
.map(listResult -> {
|
.map(listResult -> {
|
||||||
@@ -246,18 +255,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.next()
|
.next()
|
||||||
.flatMap(persona -> {
|
.flatMap(persona -> {
|
||||||
String email = persona.getSpec().getEmail();
|
String email = persona.getSpec().getEmail();
|
||||||
String avatarUrl = "";
|
String avatarUrl = GravatarUtil.generateUrl(email);
|
||||||
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(
|
return ServerResponse.ok().bodyValue(Map.of(
|
||||||
"name", persona.getSpec().getDisplayName(),
|
"name", persona.getSpec().getDisplayName(),
|
||||||
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
|
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
|
||||||
@@ -297,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
||||||
);
|
);
|
||||||
|
|
||||||
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.list(Reply.class,
|
||||||
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
|
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
||||||
|
null)
|
||||||
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
||||||
.map(reply -> {
|
.map(reply -> {
|
||||||
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
||||||
@@ -642,7 +641,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
||||||
}
|
}
|
||||||
// Read persona name from post annotations
|
// Read persona name from post annotations
|
||||||
return getPersonaNameFromComment(commentName)
|
return personaResolver.getPersonaNameFromComment(commentName)
|
||||||
.flatMap(personaName ->
|
.flatMap(personaName ->
|
||||||
orchestrator.processComment(commentName, null, false, personaName)
|
orchestrator.processComment(commentName, null, false, personaName)
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
||||||
@@ -669,7 +668,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
return ServerResponse.badRequest()
|
return ServerResponse.badRequest()
|
||||||
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
||||||
}
|
}
|
||||||
return getPersonaNameFromComment(commentName)
|
return personaResolver.getPersonaNameFromComment(commentName)
|
||||||
.flatMap(personaName ->
|
.flatMap(personaName ->
|
||||||
orchestrator.processComment(commentName, replyName, true, personaName)
|
orchestrator.processComment(commentName, replyName, true, personaName)
|
||||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
||||||
@@ -679,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.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) {
|
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||||
// First try using replyName if available
|
// First try using replyName if available
|
||||||
String replyName = record.getSpec().getReplyName();
|
String replyName = record.getSpec().getReplyName();
|
||||||
@@ -748,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||||
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.list(Comment.class, null, null)
|
||||||
|
.take(1000)
|
||||||
.collectList()
|
.collectList()
|
||||||
.map(comments -> {
|
.map(comments -> {
|
||||||
Set<String> seen = new HashSet<>();
|
Set<String> seen = new HashSet<>();
|
||||||
@@ -766,7 +741,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
|
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
|
||||||
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
||||||
} else if (!email.isBlank()) {
|
} else if (!email.isBlank()) {
|
||||||
avatarUrl = generateGravatarUrl(email);
|
avatarUrl = GravatarUtil.generateUrl(email);
|
||||||
}
|
}
|
||||||
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
||||||
}
|
}
|
||||||
@@ -776,26 +751,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateGravatarUrl(String email) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
return "https://cn.cravatar.com/avatar/" + hexString;
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
|
||||||
return Mono.fromCallable(() -> {
|
return cleanupService.getRetentionDays()
|
||||||
int retentionDays = cleanupService.getRetentionDays();
|
.flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays)
|
||||||
long deleted = cleanupService.executeCleanup(retentionDays);
|
.map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays))
|
||||||
return Map.of("deletedCount", deleted, "retentionDays", retentionDays);
|
)
|
||||||
})
|
|
||||||
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
||||||
@@ -961,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
|||||||
for (var personaData : personasList) {
|
for (var personaData : personasList) {
|
||||||
importMono = importMono.then(Mono.defer(() -> {
|
importMono = importMono.then(Mono.defer(() -> {
|
||||||
try {
|
try {
|
||||||
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
||||||
var personaJson = objectMapper.writeValueAsString(personaData);
|
var personaJson = objectMapper.writeValueAsString(personaData);
|
||||||
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
||||||
var personaName = persona.getMetadata().getName();
|
var personaName = persona.getMetadata().getName();
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,22 +4,18 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import run.halo.app.core.extension.content.Category;
|
|
||||||
import run.halo.app.core.extension.content.Comment;
|
import run.halo.app.core.extension.content.Comment;
|
||||||
import run.halo.app.core.extension.content.Post;
|
|
||||||
import run.halo.app.core.extension.content.Tag;
|
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -28,31 +24,19 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
|
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
|
||||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
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_OWNER_PREFIX = "ai-persona-";
|
||||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/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)
|
// Record the time when this bean was created (plugin startup time)
|
||||||
private final Instant pluginStartTime = Instant.now();
|
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
|
@Override
|
||||||
public Result reconcile(Request request) {
|
public Result reconcile(Request request) {
|
||||||
var name = request.name();
|
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 -> {
|
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||||
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
||||||
return;
|
return;
|
||||||
@@ -93,32 +77,18 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
client.update(comment);
|
client.update(comment);
|
||||||
|
|
||||||
// Read persona name from the post's annotations
|
// Read persona name from the post's annotations
|
||||||
String personaName = getPersonaNameFromComment(comment);
|
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
|
||||||
|
|
||||||
// Top-level comment → always trigger AI reply
|
// Top-level comment → always trigger AI reply
|
||||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||||
asyncStarted.set(true);
|
|
||||||
orchestrator.processComment(name, null, false, personaName)
|
orchestrator.processComment(name, null, false, personaName)
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
.doFinally(signal -> {
|
|
||||||
processingLocks.remove(name);
|
|
||||||
log.debug("[CommentReconciler] Released processing lock for: {}", name);
|
|
||||||
})
|
|
||||||
.subscribe(
|
.subscribe(
|
||||||
null,
|
null,
|
||||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
() -> 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();
|
return Result.doNotRetry();
|
||||||
}
|
}
|
||||||
@@ -139,61 +109,6 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
return false;
|
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 -> {
|
|
||||||
// 1. 文章注解优先
|
|
||||||
var annotations = post.getMetadata().getAnnotations();
|
|
||||||
if (annotations != null) {
|
|
||||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (persona != null && !persona.isBlank()) {
|
|
||||||
return persona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. 分类注解
|
|
||||||
var spec = post.getSpec();
|
|
||||||
if (spec != null && spec.getCategories() != null) {
|
|
||||||
for (String categoryName : spec.getCategories()) {
|
|
||||||
var cat = client.fetch(Category.class, categoryName).orElse(null);
|
|
||||||
if (cat != null) {
|
|
||||||
var catAnnotations = cat.getMetadata().getAnnotations();
|
|
||||||
if (catAnnotations != null) {
|
|
||||||
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (catPersona != null && !catPersona.isBlank()) {
|
|
||||||
return catPersona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3. 标签注解
|
|
||||||
if (spec != null && spec.getTags() != null) {
|
|
||||||
for (String tagName : spec.getTags()) {
|
|
||||||
var tag = client.fetch(Tag.class, tagName).orElse(null);
|
|
||||||
if (tag != null) {
|
|
||||||
var tagAnnotations = tag.getMetadata().getAnnotations();
|
|
||||||
if (tagAnnotations != null) {
|
|
||||||
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (tagPersona != null && !tagPersona.isBlank()) {
|
|
||||||
return tagPersona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isProcessed(Map<String, String> annotations) {
|
private boolean isProcessed(Map<String, String> annotations) {
|
||||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import run.halo.app.core.extension.content.Category;
|
|
||||||
import run.halo.app.core.extension.content.Comment;
|
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.content.Reply;
|
||||||
import run.halo.app.core.extension.content.Tag;
|
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||||
|
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -27,11 +25,11 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
|
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final AiReplyOrchestrator orchestrator;
|
private final AiReplyOrchestrator orchestrator;
|
||||||
|
private final PersonaResolver personaResolver;
|
||||||
|
|
||||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
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_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
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)
|
// Record the time when this bean was created (plugin startup time)
|
||||||
private final Instant pluginStartTime = Instant.now();
|
private final Instant pluginStartTime = Instant.now();
|
||||||
@@ -120,7 +118,9 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
client.update(reply);
|
client.update(reply);
|
||||||
|
|
||||||
// Reply to AI → trigger AI reply (conversation continuation)
|
// Reply to AI → trigger AI reply (conversation continuation)
|
||||||
String personaName = getPersonaNameFromComment(parentCommentName);
|
String personaName = client.fetch(Comment.class, parentCommentName)
|
||||||
|
.map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment))
|
||||||
|
.orElse(null);
|
||||||
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
||||||
orchestrator.processComment(parentCommentName, name, true, personaName)
|
orchestrator.processComment(parentCommentName, name, true, personaName)
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
@@ -154,65 +154,6 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
|||||||
.orElse(false);
|
.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 -> {
|
|
||||||
// 1. 文章注解优先
|
|
||||||
var annotations = post.getMetadata().getAnnotations();
|
|
||||||
if (annotations != null) {
|
|
||||||
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (persona != null && !persona.isBlank()) {
|
|
||||||
return persona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2. 分类注解
|
|
||||||
var spec = post.getSpec();
|
|
||||||
if (spec != null && spec.getCategories() != null) {
|
|
||||||
for (String categoryName : spec.getCategories()) {
|
|
||||||
var cat = client.fetch(Category.class, categoryName).orElse(null);
|
|
||||||
if (cat != null) {
|
|
||||||
var catAnnotations = cat.getMetadata().getAnnotations();
|
|
||||||
if (catAnnotations != null) {
|
|
||||||
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (catPersona != null && !catPersona.isBlank()) {
|
|
||||||
return catPersona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 3. 标签注解
|
|
||||||
if (spec != null && spec.getTags() != null) {
|
|
||||||
for (String tagName : spec.getTags()) {
|
|
||||||
var tag = client.fetch(Tag.class, tagName).orElse(null);
|
|
||||||
if (tag != null) {
|
|
||||||
var tagAnnotations = tag.getMetadata().getAnnotations();
|
|
||||||
if (tagAnnotations != null) {
|
|
||||||
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
|
||||||
if (tagPersona != null && !tagPersona.isBlank()) {
|
|
||||||
return tagPersona;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.orElse(null);
|
|
||||||
})
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isProcessed(Map<String, String> annotations) {
|
private boolean isProcessed(Map<String, String> annotations) {
|
||||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class AiReplyCleanupService implements DisposableBean {
|
public class AiReplyCleanupService implements DisposableBean {
|
||||||
@@ -27,9 +30,9 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
|
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public AiReplyCleanupService(ReactiveExtensionClient client) {
|
public AiReplyCleanupService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "ai-reply-cleanup");
|
Thread t = new Thread(r, "ai-reply-cleanup");
|
||||||
t.setDaemon(true);
|
t.setDaemon(true);
|
||||||
@@ -40,8 +43,25 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void dailyCleanup() {
|
public void dailyCleanup() {
|
||||||
try {
|
isCleanupEnabled()
|
||||||
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
.flatMap(enabled -> {
|
||||||
|
if (!Boolean.TRUE.equals(enabled)) {
|
||||||
|
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return getRetentionDays()
|
||||||
|
.flatMap(retentionDays -> executeCleanup(retentionDays)
|
||||||
|
.doOnNext(deleted -> log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.subscribe(
|
||||||
|
null,
|
||||||
|
e -> log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Boolean> isCleanupEnabled() {
|
||||||
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
var data = cm.getData();
|
var data = cm.getData();
|
||||||
if (data == null) return false;
|
if (data == null) return false;
|
||||||
@@ -55,51 +75,35 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(true)
|
.defaultIfEmpty(true);
|
||||||
.block();
|
|
||||||
|
|
||||||
if (!Boolean.TRUE.equals(enabled)) {
|
|
||||||
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int retentionDays = getRetentionDays();
|
public Mono<Long> executeCleanup(int retentionDays) {
|
||||||
long deleted = executeCleanup(retentionDays);
|
|
||||||
log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public long executeCleanup(int retentionDays) {
|
|
||||||
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
||||||
|
|
||||||
var oldRecords = client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||||
.filter(r -> {
|
.filter(r -> {
|
||||||
Instant created = r.getMetadata().getCreationTimestamp();
|
Instant created = r.getMetadata().getCreationTimestamp();
|
||||||
return created != null && created.isBefore(cutoff);
|
return created != null && created.isBefore(cutoff);
|
||||||
})
|
})
|
||||||
.collectList()
|
.collectList()
|
||||||
.block();
|
.flatMap(oldRecords -> {
|
||||||
|
if (oldRecords.isEmpty()) {
|
||||||
if (oldRecords == null || oldRecords.isEmpty()) {
|
return Mono.just(0L);
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
return Flux.fromIterable(oldRecords)
|
||||||
long deleted = 0;
|
.flatMap(record -> client.delete(record)
|
||||||
for (var record : oldRecords) {
|
.thenReturn(1L)
|
||||||
try {
|
.onErrorResume(e -> {
|
||||||
client.delete(record).block();
|
|
||||||
deleted++;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
||||||
}
|
return Mono.just(0L);
|
||||||
}
|
})
|
||||||
return deleted;
|
)
|
||||||
|
.reduce(0L, Long::sum);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getRetentionDays() {
|
public Mono<Integer> getRetentionDays() {
|
||||||
try {
|
|
||||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||||
.mapNotNull(cm -> {
|
.mapNotNull(cm -> {
|
||||||
var data = cm.getData();
|
var data = cm.getData();
|
||||||
@@ -114,11 +118,10 @@ public class AiReplyCleanupService implements DisposableBean {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.defaultIfEmpty(30)
|
.defaultIfEmpty(30)
|
||||||
.block();
|
.onErrorResume(e -> {
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
||||||
return 30;
|
return Mono.just(30);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ public class AiReplyOrchestrator {
|
|||||||
CommentReplyPublisher commentReplyPublisher,
|
CommentReplyPublisher commentReplyPublisher,
|
||||||
FilterService filterService,
|
FilterService filterService,
|
||||||
RateLimitService rateLimitService,
|
RateLimitService rateLimitService,
|
||||||
ReactiveExtensionClient client) {
|
ReactiveExtensionClient client,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.contextExtractor = contextExtractor;
|
this.contextExtractor = contextExtractor;
|
||||||
this.promptBuilder = promptBuilder;
|
this.promptBuilder = promptBuilder;
|
||||||
this.aiReplyService = aiReplyService;
|
this.aiReplyService = aiReplyService;
|
||||||
@@ -59,7 +60,7 @@ public class AiReplyOrchestrator {
|
|||||||
this.filterService = filterService;
|
this.filterService = filterService;
|
||||||
this.rateLimitService = rateLimitService;
|
this.rateLimitService = rateLimitService;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import run.halo.app.core.extension.content.Reply;
|
|||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
||||||
|
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -120,7 +119,7 @@ public class CommentReplyPublisher {
|
|||||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||||
// 使用Gravatar邮箱头像
|
// 使用Gravatar邮箱头像
|
||||||
if (email != null && !email.isBlank()) {
|
if (email != null && !email.isBlank()) {
|
||||||
String gravatarUrl = generateGravatarUrl(email);
|
String gravatarUrl = GravatarUtil.generateUrl(email);
|
||||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||||
}
|
}
|
||||||
owner.setAnnotations(ownerAnnotations);
|
owner.setAnnotations(ownerAnnotations);
|
||||||
@@ -181,22 +180,4 @@ public class CommentReplyPublisher {
|
|||||||
private String generateReplyName() {
|
private String generateReplyName() {
|
||||||
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
|
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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ public class FilterService {
|
|||||||
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
||||||
private static final String GROUP_CONTENT = "content.halo.run";
|
private static final String GROUP_CONTENT = "content.halo.run";
|
||||||
|
|
||||||
public FilterService(ReactiveExtensionClient client) {
|
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Boolean> shouldProcess(Comment comment) {
|
public Mono<Boolean> shouldProcess(Comment comment) {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.core.extension.content.Category;
|
||||||
|
import run.halo.app.core.extension.content.Comment;
|
||||||
|
import run.halo.app.core.extension.content.Post;
|
||||||
|
import run.halo.app.core.extension.content.Tag;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared service for resolving AI persona name from a comment's associated
|
||||||
|
* post/category/tag annotations.
|
||||||
|
*
|
||||||
|
* <p>Priority: Post annotation > Category annotation > Tag annotation
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PersonaResolver {
|
||||||
|
|
||||||
|
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient reactiveClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve persona name from a comment (reactive version).
|
||||||
|
* Reads the post's annotations, then falls back to category and tag annotations.
|
||||||
|
*
|
||||||
|
* @param commentName the Comment metadata.name
|
||||||
|
* @return the persona name, or empty string if none found
|
||||||
|
*/
|
||||||
|
public Mono<String> getPersonaNameFromComment(String commentName) {
|
||||||
|
return reactiveClient.fetch(Comment.class, commentName)
|
||||||
|
.flatMap(comment -> {
|
||||||
|
var subjectRef = comment.getSpec().getSubjectRef();
|
||||||
|
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
||||||
|
return Mono.just("");
|
||||||
|
}
|
||||||
|
String postName = subjectRef.getName();
|
||||||
|
return resolveFromPost(postName);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> resolveFromPost(String postName) {
|
||||||
|
return reactiveClient.fetch(Post.class, postName)
|
||||||
|
.flatMap(post -> {
|
||||||
|
// 1. Post annotation takes priority
|
||||||
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
|
if (annotations != null) {
|
||||||
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (persona != null && !persona.isBlank()) {
|
||||||
|
return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Category annotations
|
||||||
|
var spec = post.getSpec();
|
||||||
|
if (spec != null && spec.getCategories() != null) {
|
||||||
|
for (String categoryName : spec.getCategories()) {
|
||||||
|
var persona = resolveFromCategory(categoryName);
|
||||||
|
if (persona != null) return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Tag annotations
|
||||||
|
if (spec != null && spec.getTags() != null) {
|
||||||
|
for (String tagName : spec.getTags()) {
|
||||||
|
var persona = resolveFromTag(tagName);
|
||||||
|
if (persona != null) return Mono.just(persona);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Mono.just("");
|
||||||
|
})
|
||||||
|
.defaultIfEmpty("");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromCategory(String categoryName) {
|
||||||
|
// Use block() here because this is called from a Reconciler (sync context)
|
||||||
|
// For reactive context, the caller should use the reactive version
|
||||||
|
try {
|
||||||
|
return reactiveClient.fetch(Category.class, categoryName)
|
||||||
|
.mapNotNull(cat -> {
|
||||||
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
|
if (catAnnotations != null) {
|
||||||
|
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (catPersona != null && !catPersona.isBlank()) {
|
||||||
|
return catPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.block();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveFromTag(String tagName) {
|
||||||
|
try {
|
||||||
|
return reactiveClient.fetch(Tag.class, tagName)
|
||||||
|
.mapNotNull(tag -> {
|
||||||
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
|
if (tagAnnotations != null) {
|
||||||
|
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (tagPersona != null && !tagPersona.isBlank()) {
|
||||||
|
return tagPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.block();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve persona name from a comment using blocking ExtensionClient
|
||||||
|
* (for use in Reconciler sync context).
|
||||||
|
*/
|
||||||
|
public String getPersonaNameFromCommentBlocking(ExtensionClient client, 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 -> {
|
||||||
|
// 1. Post annotation
|
||||||
|
var annotations = post.getMetadata().getAnnotations();
|
||||||
|
if (annotations != null) {
|
||||||
|
String persona = annotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (persona != null && !persona.isBlank()) {
|
||||||
|
return persona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Category annotations
|
||||||
|
var spec = post.getSpec();
|
||||||
|
if (spec != null && spec.getCategories() != null) {
|
||||||
|
for (String categoryName : spec.getCategories()) {
|
||||||
|
var cat = client.fetch(Category.class, categoryName).orElse(null);
|
||||||
|
if (cat != null) {
|
||||||
|
var catAnnotations = cat.getMetadata().getAnnotations();
|
||||||
|
if (catAnnotations != null) {
|
||||||
|
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (catPersona != null && !catPersona.isBlank()) {
|
||||||
|
return catPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Tag annotations
|
||||||
|
if (spec != null && spec.getTags() != null) {
|
||||||
|
for (String tagName : spec.getTags()) {
|
||||||
|
var tag = client.fetch(Tag.class, tagName).orElse(null);
|
||||||
|
if (tag != null) {
|
||||||
|
var tagAnnotations = tag.getMetadata().getAnnotations();
|
||||||
|
if (tagAnnotations != null) {
|
||||||
|
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
|
||||||
|
if (tagPersona != null && !tagPersona.isBlank()) {
|
||||||
|
return tagPersona;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,9 +20,9 @@ public class PromptBuilder {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||||
|
|
||||||
public PromptBuilder(ReactiveExtensionClient client) {
|
public PromptBuilder(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||||
this.client = client;
|
this.client = client;
|
||||||
this.objectMapper = new ObjectMapper();
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String PRESET_FRIENDLY = """
|
private static final String PRESET_FRIENDLY = """
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package top.nxxy335.commentaiautopilot.util;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility for generating Gravatar/Cravatar avatar URLs from email addresses.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class GravatarUtil {
|
||||||
|
|
||||||
|
private static final String CRAVATAR_BASE_URL = "https://cn.cravatar.com/avatar/";
|
||||||
|
|
||||||
|
private GravatarUtil() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Cravatar URL from email address using SHA-256 hash.
|
||||||
|
*
|
||||||
|
* @param email the email address
|
||||||
|
* @return the avatar URL, or empty string if generation fails
|
||||||
|
*/
|
||||||
|
public static String generateUrl(String email) {
|
||||||
|
if (email == null || email.isBlank()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
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 CRAVATAR_BASE_URL + hexString;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to generate Gravatar URL: {}", e.getMessage());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,4 +30,4 @@ spec:
|
|||||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||||
settingName: "comment-ai-autopilot-settings"
|
settingName: "comment-ai-autopilot-settings"
|
||||||
configMapName: "comment-ai-autopilot-configmap"
|
configMapName: "comment-ai-autopilot-configmap"
|
||||||
version: "1.0.0-beta.2"
|
version: "1.0.0-beta.3"
|
||||||
|
|||||||
@@ -863,7 +863,10 @@ const fetchSettings = async () => {
|
|||||||
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
|
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
|
||||||
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
|
||||||
}
|
}
|
||||||
} catch (e) { console.error("Failed to fetch settings", e) }
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch settings", e)
|
||||||
|
Toast.error("加载设置失败,请刷新页面重试")
|
||||||
|
}
|
||||||
finally {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// Update snapshot after fetch to reset unsaved indicator
|
// Update snapshot after fetch to reset unsaved indicator
|
||||||
|
|||||||
Reference in New Issue
Block a user