feat: 增强插件可靠性与可用性
- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读 - 实现失败重试机制,指数退避策略 - 评论者黑名单支持邮箱匹配,可从评论列表选择 - 日志页面支持按状态/情感筛选和关键词搜索 - AI角色邮箱配置增加 Gravatar 头像实时预览 - 插件依赖声明修正:ai-foundation 改为必须依赖 - 新增定时清理旧记录功能,可配置保留天数 - 更新 README 和 VitePress 文档
This commit is contained in:
+95
-4
@@ -16,6 +16,7 @@ import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -29,8 +30,10 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
|
||||
@@ -40,13 +43,15 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final AiReplyCleanupService cleanupService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) {
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.cleanupService = cleanupService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@@ -65,6 +70,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
.POST("/replies/{name}/reject", this::rejectReply)
|
||||
.POST("/comments/{commentName}/trigger", this::triggerReply)
|
||||
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
|
||||
.GET("/commenters", this::listCommenters)
|
||||
.POST("/cleanup", this::triggerCleanup)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -76,10 +83,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
private Mono<ServerResponse> listReplies(ServerRequest request) {
|
||||
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
var pageable = PageRequestImpl.of(page, size, sort);
|
||||
var statusFilter = request.queryParam("status").orElse("");
|
||||
var sentimentFilter = request.queryParam("sentiment").orElse("");
|
||||
var keywordFilter = request.queryParam("keyword").orElse("");
|
||||
|
||||
return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable)
|
||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), 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;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sorted(Comparator.comparing(
|
||||
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
|
||||
Comparator.nullsLast(Comparator.reverseOrder())
|
||||
))
|
||||
.toList();
|
||||
|
||||
int total = filtered.size();
|
||||
int fromIndex = (page - 1) * size;
|
||||
int toIndex = Math.min(fromIndex + size, total);
|
||||
List<AiCommentReply> pageContent = fromIndex < total
|
||||
? filtered.subList(fromIndex, toIndex) : List.of();
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("items", pageContent);
|
||||
result.put("total", total);
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
result.put("totalPages", (int) Math.ceil((double) total / size));
|
||||
result.put("first", page == 1);
|
||||
result.put("last", toIndex >= total);
|
||||
return result;
|
||||
})
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
@@ -495,4 +545,45 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
String time,
|
||||
boolean isAi
|
||||
) {}
|
||||
|
||||
public record CommenterInfo(
|
||||
String displayName,
|
||||
String email
|
||||
) {}
|
||||
|
||||
private Mono<ServerResponse> listCommenters(ServerRequest request) {
|
||||
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.collectList()
|
||||
.map(comments -> {
|
||||
Set<String> seen = new HashSet<>();
|
||||
List<CommenterInfo> result = new ArrayList<>();
|
||||
for (var comment : comments) {
|
||||
var owner = comment.getSpec() != null ? comment.getSpec().getOwner() : null;
|
||||
if (owner == null) continue;
|
||||
String displayName = owner.getDisplayName() != null ? owner.getDisplayName() : "";
|
||||
String email = "EMAIL".equals(owner.getKind()) && owner.getName() != null
|
||||
? owner.getName() : "";
|
||||
String key = displayName.toLowerCase() + "|" + email.toLowerCase();
|
||||
if (seen.add(key)) {
|
||||
result.add(new CommenterInfo(displayName, email));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters));
|
||||
}
|
||||
|
||||
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);
|
||||
})
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to trigger cleanup: {}", e.getMessage());
|
||||
return ServerResponse.status(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.bodyValue(Map.of("message", "清理失败: " + e.getMessage()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class AiReplyCleanupService implements DisposableBean {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ScheduledExecutorService scheduler;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public AiReplyCleanupService(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "ai-reply-cleanup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
// Schedule daily cleanup: initial delay 1 minute, then every 24 hours
|
||||
this.scheduler.scheduleAtFixedRate(this::dailyCleanup, 1, 24 * 60, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public long executeCleanup(int retentionDays) {
|
||||
Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
|
||||
|
||||
var oldRecords = 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
scheduler.shutdown();
|
||||
try {
|
||||
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
scheduler.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -17,9 +18,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiReplyOrchestrator {
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
private final ContextExtractor contextExtractor;
|
||||
private final PromptBuilder promptBuilder;
|
||||
private final AiReplyService aiReplyService;
|
||||
@@ -28,12 +30,31 @@ public class AiReplyOrchestrator {
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final FilterService filterService;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// In-memory dedup: tracks which comment/reply is currently being processed
|
||||
// Prevents duplicate replies when Reconciler fires multiple times
|
||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public AiReplyOrchestrator(ContextExtractor contextExtractor,
|
||||
PromptBuilder promptBuilder,
|
||||
AiReplyService aiReplyService,
|
||||
SentimentService sentimentService,
|
||||
ReviewService reviewService,
|
||||
CommentReplyPublisher commentReplyPublisher,
|
||||
FilterService filterService,
|
||||
ReactiveExtensionClient client) {
|
||||
this.contextExtractor = contextExtractor;
|
||||
this.promptBuilder = promptBuilder;
|
||||
this.aiReplyService = aiReplyService;
|
||||
this.sentimentService = sentimentService;
|
||||
this.reviewService = reviewService;
|
||||
this.commentReplyPublisher = commentReplyPublisher;
|
||||
this.filterService = filterService;
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a new comment or reply.
|
||||
*
|
||||
@@ -151,6 +172,7 @@ public class AiReplyOrchestrator {
|
||||
|
||||
/**
|
||||
* Generate AI reply, optionally review it, then publish.
|
||||
* Includes retry logic for empty AI replies and review failures.
|
||||
*/
|
||||
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
|
||||
AiCommentReply replyRecord, String modelName) {
|
||||
@@ -159,7 +181,7 @@ public class AiReplyOrchestrator {
|
||||
.flatMap(aiReply -> {
|
||||
if (aiReply.isBlank()) {
|
||||
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
|
||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
||||
return retryOrFail(replyRecord, context, modelName, "AI generated empty reply");
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] AI generated reply for {}: {} chars",
|
||||
@@ -172,7 +194,9 @@ public class AiReplyOrchestrator {
|
||||
if ("FAIL".equals(reviewResult.status())) {
|
||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||
context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then();
|
||||
// Save the failed reply content, then retry
|
||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false)
|
||||
.then(retryOrFail(replyRecord, context, modelName, "Content safety review failed"));
|
||||
}
|
||||
return publishReply(context, aiReply, replyRecord, reviewResult.score());
|
||||
})
|
||||
@@ -186,6 +210,73 @@ public class AiReplyOrchestrator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to retry or mark as final FAIL.
|
||||
* If retryCount < maxRetryCount, increment retryCount, set status to PENDING,
|
||||
* delay with exponential backoff, then re-execute the generate+review+publish flow.
|
||||
* Otherwise, mark as final FAIL.
|
||||
*/
|
||||
private Mono<Void> retryOrFail(AiCommentReply replyRecord,
|
||||
ContextExtractor.CommentContext context,
|
||||
String modelName,
|
||||
String reason) {
|
||||
return getMaxRetryCount().flatMap(maxRetry -> {
|
||||
int currentRetryCount = replyRecord.getSpec().getRetryCount() != null
|
||||
? replyRecord.getSpec().getRetryCount() : 0;
|
||||
|
||||
if (currentRetryCount < maxRetry) {
|
||||
int newRetryCount = currentRetryCount + 1;
|
||||
long delaySeconds = 5L * (1L << currentRetryCount); // 5 * 2^retryCount
|
||||
log.info("[Orchestrator] Retrying ({}/{}) for {} after {}s, reason: {}",
|
||||
newRetryCount, maxRetry, context.commentId(), delaySeconds, reason);
|
||||
|
||||
// Update retryCount and reset status to PENDING
|
||||
return updateRecordForRetry(replyRecord, newRetryCount)
|
||||
.delayElement(Duration.ofSeconds(delaySeconds))
|
||||
.then(retryGenerate(context, replyRecord, modelName));
|
||||
} else {
|
||||
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
|
||||
maxRetry, context.commentId(), reason);
|
||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-execute the core generate+review+publish flow for a retry.
|
||||
* Rebuilds the prompt from context and sentiment, then calls generateAndPublish again.
|
||||
*/
|
||||
private Mono<Void> retryGenerate(ContextExtractor.CommentContext context,
|
||||
AiCommentReply replyRecord,
|
||||
String modelName) {
|
||||
return sentimentService.analyzeSentiment(context.commentContent(), modelName)
|
||||
.flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment())
|
||||
.flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the record's retryCount and reset status to PENDING for a retry attempt.
|
||||
*/
|
||||
private Mono<AiCommentReply> updateRecordForRetry(AiCommentReply record, int newRetryCount) {
|
||||
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setRetryCount(newRetryCount);
|
||||
latest.getSpec().setStatus("PENDING");
|
||||
latest.getSpec().setReply("");
|
||||
latest.getSpec().setScore(0);
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException)
|
||||
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying retry-update for {} due to optimistic lock",
|
||||
record.getMetadata().getName()))
|
||||
)
|
||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated for retry: retryCount={}",
|
||||
record.getMetadata().getName(), newRetryCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the reply and update the record to PASS + published=true.
|
||||
*/
|
||||
@@ -205,41 +296,104 @@ public class AiReplyOrchestrator {
|
||||
}
|
||||
|
||||
private Mono<String> getModelName() {
|
||||
return settingFetcher.getSettingValue("model")
|
||||
.map(node -> {
|
||||
var nameNode = node.get("modelName");
|
||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||
return nameNode.asText();
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String modelJson = data.get("model");
|
||||
if (modelJson == null || modelJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(modelJson);
|
||||
JsonNode nameNode = node.get("modelName");
|
||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||
return nameNode.asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Orchestrator] Failed to parse modelName from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return "";
|
||||
return null;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage());
|
||||
return Mono.just("");
|
||||
log.debug("[Orchestrator] Failed to fetch model setting from ConfigMap: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoReplyEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true))
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
if (!node.has("autoReply")) {
|
||||
return true;
|
||||
}
|
||||
return node.get("autoReply").asBoolean(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Orchestrator] Failed to parse autoReply from ConfigMap: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
log.debug("[Orchestrator] Failed to fetch autoReply setting from ConfigMap: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoPublishEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true))
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
if (!node.has("autoPublish")) {
|
||||
return true;
|
||||
}
|
||||
return node.get("autoPublish").asBoolean(true);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Orchestrator] Failed to parse autoPublish from ConfigMap: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
log.debug("[Orchestrator] Failed to fetch autoPublish setting from ConfigMap: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<Integer> getMaxRetryCount() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
if (node.has("maxRetryCount")) {
|
||||
return node.get("maxRetryCount").asInt(3);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Orchestrator] Failed to parse maxRetryCount from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch maxRetryCount setting from ConfigMap: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(3);
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
|
||||
AiCommentReply record = new AiCommentReply();
|
||||
record.setMetadata(new Metadata());
|
||||
|
||||
@@ -70,8 +70,9 @@ public class FilterService {
|
||||
? node.get("blockedCommenters").asText("") : "";
|
||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
||||
String commenterName = getCommenterDisplayName(comment);
|
||||
if (isInList(commenterName, blockedCommenters)) {
|
||||
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName);
|
||||
String commenterEmail = getCommenterEmail(comment);
|
||||
if (isInList(commenterName, blockedCommenters) || isInList(commenterEmail, blockedCommenters)) {
|
||||
log.info("[Filter] Commenter '{}' (email: '{}') is in blocked list, skipping", commenterName, commenterEmail);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -130,6 +131,16 @@ public class FilterService {
|
||||
return displayName != null ? displayName : "";
|
||||
}
|
||||
|
||||
private String getCommenterEmail(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if ("EMAIL".equals(owner.getKind())) {
|
||||
var name = owner.getName();
|
||||
return name != null ? name : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private List<String> parseList(String str) {
|
||||
if (str == null || str.isBlank()) return Collections.emptyList();
|
||||
return Arrays.stream(str.split(","))
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PromptBuilder {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public PromptBuilder(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
private static final String SAFETY_PROMPT = """
|
||||
【安全规范】
|
||||
@@ -76,13 +84,22 @@ public class PromptBuilder {
|
||||
}
|
||||
|
||||
private Mono<String> getPromptTemplate() {
|
||||
return settingFetcher.getSettingValue("prompt")
|
||||
.map(node -> {
|
||||
var templateNode = node.get("customPromptTemplate");
|
||||
if (templateNode != null && !templateNode.asText().isBlank()) {
|
||||
return templateNode.asText();
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String promptJson = data.get("prompt");
|
||||
if (promptJson == null || promptJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(promptJson);
|
||||
JsonNode templateNode = node.get("customPromptTemplate");
|
||||
if (templateNode != null && !templateNode.asText().isBlank()) {
|
||||
return templateNode.asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse customPromptTemplate from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return DEFAULT_PROMPT_TEMPLATE;
|
||||
return null;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
|
||||
@@ -92,13 +109,22 @@ public class PromptBuilder {
|
||||
}
|
||||
|
||||
private Mono<String> getPersonaPrompt() {
|
||||
return settingFetcher.getSettingValue("persona")
|
||||
.map(node -> {
|
||||
var promptNode = node.get("personaPrompt");
|
||||
if (promptNode != null && !promptNode.asText().isBlank()) {
|
||||
return promptNode.asText();
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return null;
|
||||
String personaJson = data.get("persona");
|
||||
if (personaJson == null || personaJson.isBlank()) return null;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(personaJson);
|
||||
JsonNode promptNode = node.get("personaPrompt");
|
||||
if (promptNode != null && !promptNode.asText().isBlank()) {
|
||||
return promptNode.asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse personaPrompt from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return DEFAULT_PERSONA_PROMPT;
|
||||
return null;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
|
||||
|
||||
@@ -24,7 +24,7 @@ spec:
|
||||
- $formkit: textarea
|
||||
name: blockedCommenters
|
||||
label: 评论者黑名单
|
||||
help: 输入评论者显示名称,多个用逗号分隔。这些评论者的评论不会触发AI回复
|
||||
help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复
|
||||
value: ""
|
||||
- group: persona
|
||||
label: AI角色设置
|
||||
@@ -57,4 +57,18 @@ spec:
|
||||
name: customPromptTemplate
|
||||
label: 自定义Prompt模板
|
||||
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
||||
- group: cleanup
|
||||
label: 数据清理
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: cleanupEnabled
|
||||
label: 启用自动清理
|
||||
value: true
|
||||
- $formkit: number
|
||||
name: retentionDays
|
||||
label: 保留天数
|
||||
help: 超过此天数的AI回复记录将被自动清理
|
||||
value: 30
|
||||
min: 1
|
||||
max: 365
|
||||
|
||||
|
||||
@@ -24,4 +24,4 @@ spec:
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.1-w5s2t7"
|
||||
pluginDependencies:
|
||||
ai-foundation?: "*"
|
||||
ai-foundation: "*"
|
||||
|
||||
Reference in New Issue
Block a user