feat: 增强插件可靠性与可用性

- 修复 PromptBuilder ClassLoader 冲突,改为 ConfigMap 直读
- 实现失败重试机制,指数退避策略
- 评论者黑名单支持邮箱匹配,可从评论列表选择
- 日志页面支持按状态/情感筛选和关键词搜索
- AI角色邮箱配置增加 Gravatar 头像实时预览
- 插件依赖声明修正:ai-foundation 改为必须依赖
- 新增定时清理旧记录功能,可配置保留天数
- 更新 README 和 VitePress 文档
This commit is contained in:
sunny-335
2026-06-14 23:07:22 +08:00
parent e03ba7a20d
commit ef3cdf4bdc
20 changed files with 946 additions and 81 deletions
@@ -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());
+15 -1
View File
@@ -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
+1 -1
View File
@@ -24,4 +24,4 @@ spec:
configMapName: "comment-ai-autopilot-configmap"
version: "0.0.1-w5s2t7"
pluginDependencies:
ai-foundation?: "*"
ai-foundation: "*"