refactor: 全面优化完善 - ObjectMapper注入、索引优化、代码去重、Bug修复
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@ plugins {
|
||||
}
|
||||
|
||||
group 'top.nxxy335.commentaiautopilot'
|
||||
version '1.0.0-beta.2'
|
||||
version '1.0.0-beta.3'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
||||
@@ -43,6 +43,10 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
.indexFunc(ext -> ext.getSpec().getPostId()));
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
||||
.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);
|
||||
|
||||
|
||||
+35
-76
@@ -8,7 +8,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Comment;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.Reply;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
@@ -16,6 +15,7 @@ import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
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.PageRequestImpl;
|
||||
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.AiReplyOrchestrator;
|
||||
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.ObjectMapper;
|
||||
@@ -56,16 +58,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private final AiFoundationClient aiFoundationClient;
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PersonaResolver personaResolver;
|
||||
|
||||
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.orchestrator = orchestrator;
|
||||
this.cleanupService = cleanupService;
|
||||
this.aiFoundationClient = aiFoundationClient;
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper = objectMapper;
|
||||
this.personaResolver = personaResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -133,19 +137,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
final Instant finalStartInstant = startInstant;
|
||||
final Instant finalEndInstant = endInstant;
|
||||
|
||||
// Check if we need in-memory filtering (keyword, date range, status, or sentiment)
|
||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null
|
||||
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
|
||||
// Check if we need in-memory filtering (keyword or date range)
|
||||
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|
||||
|
||||
// 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) {
|
||||
// Fall back to listAll + in-memory filter for complex queries
|
||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
// Fall back to listAll + in-memory filter for keyword/date queries
|
||||
return client.listAll(AiCommentReply.class, listOptions, Sort.unsorted())
|
||||
.collectList()
|
||||
.map(replies -> {
|
||||
var filtered = replies.stream()
|
||||
.filter(r -> {
|
||||
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
|
||||
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) return false;
|
||||
if (!keywordFilter.isBlank()) {
|
||||
String reply = r.getSpec().getReply();
|
||||
if (reply == null || !reply.contains(keywordFilter)) return false;
|
||||
@@ -184,13 +195,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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.by(Sort.Order.asc("metadata.creationTimestamp"))
|
||||
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
|
||||
var listOptions = ListOptions.builder().build();
|
||||
|
||||
return client.listBy(AiCommentReply.class, listOptions,
|
||||
PageRequestImpl.of(page - 1, size, sort))
|
||||
.map(listResult -> {
|
||||
@@ -246,18 +255,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.next()
|
||||
.flatMap(persona -> {
|
||||
String email = persona.getSpec().getEmail();
|
||||
String avatarUrl = "";
|
||||
if (email != null && !email.isBlank()) {
|
||||
try {
|
||||
var digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
var hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
avatarUrl = "https://cn.cravatar.com/avatar/" + hexString;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
String avatarUrl = GravatarUtil.generateUrl(email);
|
||||
return ServerResponse.ok().bodyValue(Map.of(
|
||||
"name", persona.getSpec().getDisplayName(),
|
||||
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
|
||||
@@ -297,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
||||
);
|
||||
|
||||
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
|
||||
return client.list(Reply.class,
|
||||
reply -> commentName.equals(reply.getSpec().getCommentName()),
|
||||
null)
|
||||
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
||||
.map(reply -> {
|
||||
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
||||
@@ -642,7 +641,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.bodyValue(Map.of("message", "该评论已有AI回复记录"));
|
||||
}
|
||||
// Read persona name from post annotations
|
||||
return getPersonaNameFromComment(commentName)
|
||||
return personaResolver.getPersonaNameFromComment(commentName)
|
||||
.flatMap(personaName ->
|
||||
orchestrator.processComment(commentName, null, false, personaName)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
||||
@@ -669,7 +668,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
||||
}
|
||||
return getPersonaNameFromComment(commentName)
|
||||
return personaResolver.getPersonaNameFromComment(commentName)
|
||||
.flatMap(personaName ->
|
||||
orchestrator.processComment(commentName, replyName, true, personaName)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
||||
@@ -679,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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) {
|
||||
// First try using replyName if available
|
||||
String replyName = record.getSpec().getReplyName();
|
||||
@@ -748,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
) {}
|
||||
|
||||
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()
|
||||
.map(comments -> {
|
||||
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) {
|
||||
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
|
||||
} else if (!email.isBlank()) {
|
||||
avatarUrl = generateGravatarUrl(email);
|
||||
avatarUrl = GravatarUtil.generateUrl(email);
|
||||
}
|
||||
result.add(new CommenterInfo(displayName, email, avatarUrl));
|
||||
}
|
||||
@@ -776,26 +751,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.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) {
|
||||
return Mono.fromCallable(() -> {
|
||||
int retentionDays = cleanupService.getRetentionDays();
|
||||
long deleted = cleanupService.executeCleanup(retentionDays);
|
||||
return Map.of("deletedCount", deleted, "retentionDays", retentionDays);
|
||||
})
|
||||
return cleanupService.getRetentionDays()
|
||||
.flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays)
|
||||
.map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays))
|
||||
)
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
||||
@@ -961,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
for (var personaData : personasList) {
|
||||
importMono = importMono.then(Mono.defer(() -> {
|
||||
try {
|
||||
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
var personaJson = objectMapper.writeValueAsString(personaData);
|
||||
var persona = objectMapper.readValue(personaJson, AiPersona.class);
|
||||
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 org.springframework.stereotype.Component;
|
||||
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.Post;
|
||||
import run.halo.app.core.extension.content.Tag;
|
||||
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 top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||
|
||||
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
|
||||
@@ -28,97 +24,71 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final PersonaResolver personaResolver;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
||||
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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
// 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);
|
||||
|
||||
// Read persona name from the post's annotations
|
||||
String personaName = getPersonaNameFromComment(comment);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||
asyncStarted.set(true);
|
||||
orchestrator.processComment(name, null, false, personaName)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Read persona name from the post's annotations
|
||||
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||
orchestrator.processComment(name, null, false, personaName)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||
);
|
||||
});
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
@@ -139,61 +109,6 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read persona name from the post's annotations associated with this comment.
|
||||
*/
|
||||
private String getPersonaNameFromComment(Comment comment) {
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
||||
return null;
|
||||
}
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.map(post -> {
|
||||
// 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) {
|
||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||
}
|
||||
|
||||
@@ -4,17 +4,15 @@ 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.Category;
|
||||
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.Tag;
|
||||
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 top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
@@ -27,11 +25,11 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final PersonaResolver personaResolver;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
|
||||
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
|
||||
|
||||
// Record the time when this bean was created (plugin startup time)
|
||||
private final Instant pluginStartTime = Instant.now();
|
||||
@@ -120,7 +118,9 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
client.update(reply);
|
||||
|
||||
// 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);
|
||||
orchestrator.processComment(parentCommentName, name, true, personaName)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
@@ -154,65 +154,6 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read persona name from the post's annotations associated with the parent comment.
|
||||
*/
|
||||
private String getPersonaNameFromComment(String commentName) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.map(comment -> {
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
|
||||
return null;
|
||||
}
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(Post.class, postName)
|
||||
.map(post -> {
|
||||
// 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) {
|
||||
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.TimeUnit;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
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";
|
||||
|
||||
public AiReplyCleanupService(ReactiveExtensionClient client) {
|
||||
public AiReplyCleanupService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper = objectMapper;
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "ai-reply-cleanup");
|
||||
t.setDaemon(true);
|
||||
@@ -40,85 +43,85 @@ public class AiReplyCleanupService implements DisposableBean {
|
||||
}
|
||||
|
||||
public void dailyCleanup() {
|
||||
try {
|
||||
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
String cleanupJson = data.get("cleanup");
|
||||
if (cleanupJson == null || cleanupJson.isBlank()) return true;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||
return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(true)
|
||||
.block();
|
||||
|
||||
if (!Boolean.TRUE.equals(enabled)) {
|
||||
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
int retentionDays = getRetentionDays();
|
||||
long deleted = executeCleanup(retentionDays);
|
||||
log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays);
|
||||
} catch (Exception e) {
|
||||
log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e);
|
||||
}
|
||||
isCleanupEnabled()
|
||||
.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)
|
||||
);
|
||||
}
|
||||
|
||||
public long executeCleanup(int retentionDays) {
|
||||
private Mono<Boolean> isCleanupEnabled() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
String cleanupJson = data.get("cleanup");
|
||||
if (cleanupJson == null || cleanupJson.isBlank()) return true;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||
return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage());
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
public Mono<Long> executeCleanup(int retentionDays) {
|
||||
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 -> {
|
||||
Instant created = r.getMetadata().getCreationTimestamp();
|
||||
return created != null && created.isBefore(cutoff);
|
||||
})
|
||||
.collectList()
|
||||
.block();
|
||||
|
||||
if (oldRecords == null || oldRecords.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
long deleted = 0;
|
||||
for (var record : oldRecords) {
|
||||
try {
|
||||
client.delete(record).block();
|
||||
deleted++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
.flatMap(oldRecords -> {
|
||||
if (oldRecords.isEmpty()) {
|
||||
return Mono.just(0L);
|
||||
}
|
||||
return Flux.fromIterable(oldRecords)
|
||||
.flatMap(record -> client.delete(record)
|
||||
.thenReturn(1L)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
|
||||
return Mono.just(0L);
|
||||
})
|
||||
)
|
||||
.reduce(0L, Long::sum);
|
||||
});
|
||||
}
|
||||
|
||||
public int getRetentionDays() {
|
||||
try {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return 30;
|
||||
String cleanupJson = data.get("cleanup");
|
||||
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
||||
} catch (Exception e) {
|
||||
return 30;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(30)
|
||||
.block();
|
||||
} catch (Exception e) {
|
||||
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
||||
return 30;
|
||||
}
|
||||
public Mono<Integer> getRetentionDays() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return 30;
|
||||
String cleanupJson = data.get("cleanup");
|
||||
if (cleanupJson == null || cleanupJson.isBlank()) return 30;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(cleanupJson);
|
||||
return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30;
|
||||
} catch (Exception e) {
|
||||
return 30;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(30)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
|
||||
return Mono.just(30);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -49,7 +49,8 @@ public class AiReplyOrchestrator {
|
||||
CommentReplyPublisher commentReplyPublisher,
|
||||
FilterService filterService,
|
||||
RateLimitService rateLimitService,
|
||||
ReactiveExtensionClient client) {
|
||||
ReactiveExtensionClient client,
|
||||
ObjectMapper objectMapper) {
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.promptBuilder = promptBuilder;
|
||||
this.aiReplyService = aiReplyService;
|
||||
@@ -59,7 +60,7 @@ public class AiReplyOrchestrator {
|
||||
this.filterService = filterService;
|
||||
this.rateLimitService = rateLimitService;
|
||||
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.ReactiveExtensionClient;
|
||||
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.util.HashMap;
|
||||
import java.util.Map;
|
||||
@@ -120,7 +119,7 @@ public class CommentReplyPublisher {
|
||||
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
|
||||
// 使用Gravatar邮箱头像
|
||||
if (email != null && !email.isBlank()) {
|
||||
String gravatarUrl = generateGravatarUrl(email);
|
||||
String gravatarUrl = GravatarUtil.generateUrl(email);
|
||||
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
|
||||
}
|
||||
owner.setAnnotations(ownerAnnotations);
|
||||
@@ -181,22 +180,4 @@ public class CommentReplyPublisher {
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 GROUP_CONTENT = "content.halo.run";
|
||||
|
||||
public FilterService(ReactiveExtensionClient client) {
|
||||
public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
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 static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public PromptBuilder(ReactiveExtensionClient client) {
|
||||
public PromptBuilder(ReactiveExtensionClient client, ObjectMapper objectMapper) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
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"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
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(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 {
|
||||
loading.value = false
|
||||
// Update snapshot after fetch to reset unsaved indicator
|
||||
|
||||
Reference in New Issue
Block a user