feat: 草稿模式修复、回复编辑、预设扩展、多语言回复、文档全面更新

- 修复草稿模式下仍自动发布评论的Bug
- 新增草稿模式下编辑AI回复内容功能
- 新增3个Prompt预设(技术解答型/鼓励型/知识科普型)
- 新增多语言回复指令
- 新增日志页面时间范围筛选
- 新增AI角色排序功能
- 新增配置导入/导出功能
- 修复processingLocks内存泄漏
- 修复OptimisticLockingFailureException缺少重试
- 修复publishReply空Mono导致审核挂起
- 移除近7日回复趋势卡片
- 版本号更新至0.0.0-ygkszvd
- 全面更新插件文档
This commit is contained in:
sunny-335
2026-06-16 18:52:42 +08:00
parent 6f1cb4b037
commit 72726da9b8
26 changed files with 1217 additions and 276 deletions
@@ -13,8 +13,10 @@ 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;
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.ReactiveExtensionClient;
import run.halo.app.extension.PageRequestImpl;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
@@ -22,11 +24,15 @@ import top.nxxy335.commentaiautopilot.extension.AiPersona;
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 com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import reactor.util.retry.Retry;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
@@ -49,15 +55,17 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private final AiReplyOrchestrator orchestrator;
private final AiReplyCleanupService cleanupService;
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
private final CommentReplyPublisher commentReplyPublisher;
private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider<AiFoundationClient> aiFoundationClientProvider, CommentReplyPublisher commentReplyPublisher) {
this.client = client;
this.orchestrator = orchestrator;
this.cleanupService = cleanupService;
this.aiFoundationClientProvider = aiFoundationClientProvider;
this.commentReplyPublisher = commentReplyPublisher;
this.objectMapper = new ObjectMapper();
}
@@ -84,6 +92,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.POST("/personas", this::createPersona)
.PUT("/personas/{name}", this::updatePersona)
.DELETE("/personas/{name}", this::deletePersona)
// 导出配置
.GET("/export", this::exportConfig)
// 导入配置
.POST("/import", this::importConfig)
// 更新草稿回复内容(同时更新 AiCommentReply 和 Reply 扩展)
.PUT("/replies/{name}/content", this::updateReplyContent)
.build();
}
@@ -98,48 +112,108 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var statusFilter = request.queryParam("status").orElse("");
var sentimentFilter = request.queryParam("sentiment").orElse("");
var keywordFilter = request.queryParam("keyword").orElse("");
var startDateStr = request.queryParam("startDate").orElse("");
var endDateStr = request.queryParam("endDate").orElse("");
var sortOrder = request.queryParam("sortOrder").orElse("desc");
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;
// Parse date filters
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
ZoneId zoneId = ZoneId.systemDefault();
Instant startInstant = null;
Instant endInstant = null;
try {
if (!startDateStr.isBlank()) {
startInstant = LocalDate.parse(startDateStr, dateFormatter).atStartOfDay(zoneId).toInstant();
}
if (!endDateStr.isBlank()) {
endInstant = LocalDate.parse(endDateStr, dateFormatter).plusDays(1).atStartOfDay(zoneId).toInstant();
}
} catch (Exception e) {
log.warn("Failed to parse date filter: {}", e.getMessage());
}
final Instant finalStartInstant = startInstant;
final Instant finalEndInstant = endInstant;
// Check if we need in-memory filtering (keyword or date range)
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
if (needsMemoryFilter) {
// Fall back to listAll + in-memory filter for complex queries
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;
}
}
if (finalStartInstant != null || finalEndInstant != null) {
Instant creationTs = r.getMetadata().getCreationTimestamp();
if (creationTs == null) return false;
if (finalStartInstant != null && creationTs.isBefore(finalStartInstant)) return false;
if (finalEndInstant != null && !creationTs.isBefore(finalEndInstant)) return false;
}
return true;
})
.sorted(Comparator.comparing(
(AiCommentReply r) -> r.getMetadata().getCreationTimestamp(),
Comparator.nullsLast("asc".equalsIgnoreCase(sortOrder)
? Comparator.<Instant>naturalOrder() : Comparator.<Instant>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));
}
// Simple filters only - use server-side pagination
Sort sort = "asc".equalsIgnoreCase(sortOrder)
? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
: Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
var listOptions = ListOptions.builder().build();
// Note: Halo's ListOptions fieldSelector support may be limited
// For status and sentiment, we'll still filter in memory but with paginated data
return client.listBy(AiCommentReply.class, listOptions,
PageRequestImpl.of(page - 1, size, sort))
.map(listResult -> {
var items = listResult.getItems();
// Apply status/sentiment filter in memory on the current page
var filtered = items.stream()
.filter(r -> {
if (!statusFilter.isBlank() && !statusFilter.equals(r.getSpec().getStatus())) return false;
if (!sentimentFilter.isBlank() && !sentimentFilter.equals(r.getSpec().getSentiment())) 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("items", filtered);
result.put("total", listResult.getTotal());
result.put("page", page);
result.put("size", size);
result.put("totalPages", (int) Math.ceil((double) total / size));
result.put("totalPages", (int) Math.ceil((double) listResult.getTotal() / size));
result.put("first", page == 1);
result.put("last", toIndex >= total);
result.put("last", page >= (int) Math.ceil((double) listResult.getTotal() / size));
return result;
})
.flatMap(result -> ServerResponse.ok().bodyValue(result));
@@ -362,22 +436,53 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var name = request.pathVariable("name");
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
// Find the corresponding Reply and set approved=true
return findReplyForRecord(record)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.then(Mono.defer(() -> {
// Update AiCommentReply record
return client.fetch(AiCommentReply.class, name)
String replyName = record.getSpec().getReplyName();
if (replyName == null || replyName.isBlank()) {
// Draft mode: no Reply extension exists, create one with approved=true
return commentReplyPublisher.publishReply(
record.getSpec().getCommentId(),
record.getSpec().getReply(),
record.getSpec().getPostId(),
record.getSpec().getReplyTo(),
true,
record.getSpec().getPersonaName()
)
.switchIfEmpty(Mono.defer(() -> {
log.warn("[Endpoint] publishReply returned empty for draft approval of {}, AI reply may already exist", name);
return Mono.error(new IllegalStateException("AI回复已存在,无法重复发布"));
}))
.flatMap(publishedReply -> {
String newReplyName = publishedReply.getMetadata().getName();
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setReplyName(newReplyName);
latest.getSpec().setPublished(true);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException));
})
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
} else {
// Reply extension already exists, set approved=true
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setPublished(true);
return client.update(latest);
});
}))
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
))
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
}
})
.switchIfEmpty(ServerResponse.notFound().build());
}
@@ -386,18 +491,28 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
var name = request.pathVariable("name");
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
// Delete the draft Reply if it exists
return findReplyForRecord(record)
.flatMap(reply -> client.delete(reply))
.then(Mono.defer(() -> {
// Update AiCommentReply record status to REJECTED
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
return client.update(latest);
});
}))
String replyName = record.getSpec().getReplyName();
Mono<Void> deleteReplyMono;
if (replyName != null && !replyName.isBlank()) {
// Reply extension exists, delete it
deleteReplyMono = client.fetch(Reply.class, replyName)
.flatMap(reply -> client.delete(reply))
.then();
} else {
// No Reply extension in draft mode, nothing to delete
deleteReplyMono = Mono.empty();
}
return deleteReplyMono
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
latest.getSpec().setReplyName(null);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
))
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
})
.switchIfEmpty(ServerResponse.notFound().build());
@@ -418,19 +533,51 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return Flux.fromIterable(names)
.flatMap(name ->
client.fetch(AiCommentReply.class, name)
.flatMap(record -> findReplyForRecord(record)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setPublished(true);
return client.update(latest);
})))
.thenReturn(true)
)
.flatMap(record -> {
String replyName = record.getSpec().getReplyName();
if (replyName == null || replyName.isBlank()) {
// Draft mode: no Reply extension exists, create one with approved=true
return commentReplyPublisher.publishReply(
record.getSpec().getCommentId(),
record.getSpec().getReply(),
record.getSpec().getPostId(),
record.getSpec().getReplyTo(),
true,
record.getSpec().getPersonaName()
)
.switchIfEmpty(Mono.error(new IllegalStateException("AI回复已存在,无法重复发布")))
.flatMap(publishedReply -> {
String newReplyName = publishedReply.getMetadata().getName();
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setReplyName(newReplyName);
latest.getSpec().setPublished(true);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException));
});
} else {
// Reply extension already exists, set approved=true
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
reply.getSpec().setApproved(true);
reply.getSpec().setApprovedTime(Instant.now());
return client.update(reply);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setPublished(true);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
));
}
})
.thenReturn(true)
.onErrorResume(e -> {
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
return Mono.just(false);
@@ -462,16 +609,29 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return Flux.fromIterable(names)
.flatMap(name ->
client.fetch(AiCommentReply.class, name)
.flatMap(record -> findReplyForRecord(record)
.flatMap(reply -> client.delete(reply))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
return client.update(latest);
})))
.thenReturn(true)
)
.flatMap(record -> {
String replyName = record.getSpec().getReplyName();
Mono<Void> deleteReplyMono;
if (replyName != null && !replyName.isBlank()) {
deleteReplyMono = client.fetch(Reply.class, replyName)
.flatMap(reply -> client.delete(reply))
.then();
} else {
deleteReplyMono = Mono.empty();
}
return deleteReplyMono
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setStatus("REJECTED");
latest.getSpec().setPublished(false);
latest.getSpec().setReplyName(null);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
))
.thenReturn(true);
})
.onErrorResume(e -> {
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
return Mono.just(false);
@@ -600,8 +760,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
}
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
// Find the Reply that belongs to the same comment and was created by AI
// If the record has a quoteReply, match by that too for precision
// First try using replyName if available
String replyName = record.getSpec().getReplyName();
if (replyName != null && !replyName.isBlank()) {
return client.fetch(Reply.class, replyName);
}
// Fallback: find the Reply by commentName + owner annotations
return client.list(Reply.class,
reply -> {
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
@@ -634,7 +798,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
public record CommenterInfo(
String displayName,
String email
String email,
String avatarUrl
) {}
private Mono<ServerResponse> listCommenters(ServerRequest request) {
@@ -647,11 +812,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
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
String email = Comment.CommentOwner.KIND_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));
// 优先使用 owner 注解中的头像,否则用邮箱生成 Gravatar
String avatarUrl = "";
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);
}
result.add(new CommenterInfo(displayName, email, avatarUrl));
}
}
return result;
@@ -659,6 +831,20 @@ 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();
@@ -703,6 +889,13 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private Mono<ServerResponse> listPersonas(ServerRequest request) {
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.map(personas -> personas.stream()
.sorted(Comparator.comparing(
(AiPersona p) -> p.getSpec() != null ? p.getSpec().getPriority() : null,
Comparator.nullsLast(Comparator.naturalOrder())
))
.toList()
)
.flatMap(personas -> ServerResponse.ok().bodyValue(personas));
}
@@ -742,6 +935,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
existing.setSpec(updatedPersona.getSpec());
return client.update(existing);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.flatMap(saved -> ServerResponse.ok().bodyValue(saved))
.onErrorResume(e -> {
log.warn("Failed to update persona {}: {}", name, e.getMessage());
@@ -765,4 +960,159 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
})
.switchIfEmpty(ServerResponse.notFound().build());
}
private Mono<ServerResponse> exportConfig(ServerRequest request) {
var result = new java.util.LinkedHashMap<String, Object>();
// 导出 ConfigMap
return client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME)
.map(configMap -> {
result.put("configMap", configMap.getData());
return result;
})
.defaultIfEmpty(result)
.flatMap(r -> {
// 导出所有 AiPersona
return client.list(AiPersona.class, null, null)
.collectList()
.map(personas -> {
r.put("personas", personas);
return r;
});
})
.flatMap(r -> ServerResponse.ok()
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.bodyValue(r));
}
private Mono<ServerResponse> importConfig(ServerRequest request) {
return request.bodyToMono(java.util.Map.class)
.flatMap(body -> {
if (body == null || !body.containsKey("configMap") && !body.containsKey("personas")) {
return ServerResponse.badRequest()
.bodyValue(java.util.Map.of("error", "无效的配置格式"));
}
var results = new java.util.ArrayList<String>();
Mono<Void> importMono = Mono.empty();
// 导入 ConfigMap
if (body.containsKey("configMap")) {
@SuppressWarnings("unchecked")
var configMapData = (java.util.Map<String, String>) body.get("configMap");
importMono = importMono.then(
client.fetch(run.halo.app.extension.ConfigMap.class, CONFIG_MAP_NAME)
.flatMap(existing -> {
existing.setData(configMapData);
return client.update(existing)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.doOnSuccess(v -> results.add("ConfigMap 已更新"))
.then();
})
.switchIfEmpty(Mono.defer(() -> {
run.halo.app.extension.ConfigMap cm = new run.halo.app.extension.ConfigMap();
cm.setMetadata(new run.halo.app.extension.Metadata());
cm.getMetadata().setName(CONFIG_MAP_NAME);
cm.setData(configMapData);
return client.create(cm)
.doOnSuccess(v -> results.add("ConfigMap 已创建"))
.then();
}))
);
}
// 导入 AiPersona
if (body.containsKey("personas")) {
@SuppressWarnings("unchecked")
var personasList = (java.util.List<java.util.Map<String, Object>>) body.get("personas");
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();
return client.fetch(AiPersona.class, personaName)
.flatMap(existing -> {
persona.getMetadata().setVersion(existing.getMetadata().getVersion());
return client.update(persona)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已更新"))
.then();
})
.switchIfEmpty(client.create(persona)
.doOnSuccess(v -> results.add("角色 '" + persona.getSpec().getDisplayName() + "' 已创建"))
.then());
} catch (Exception e) {
results.add("导入角色失败: " + e.getMessage());
return Mono.<Void>empty();
}
}));
}
}
return importMono.then(
ServerResponse.ok().bodyValue(java.util.Map.of("results", results))
);
})
.onErrorResume(e -> ServerResponse.badRequest()
.bodyValue(java.util.Map.of("error", "导入失败: " + e.getMessage())));
}
private Mono<ServerResponse> updateReplyContent(ServerRequest request) {
var name = request.pathVariable("name");
return request.bodyToMono(String.class)
.flatMap(body -> {
String newReply;
try {
JsonNode node = objectMapper.readTree(body);
JsonNode replyNode = node.get("reply");
if (replyNode == null || replyNode.asText().isBlank()) {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "reply 字段不能为空"));
}
newReply = replyNode.asText();
} catch (Exception e) {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "请求体格式错误"));
}
return client.fetch(AiCommentReply.class, name)
.flatMap(record -> {
// Only allow when published is false (draft mode)
if (Boolean.TRUE.equals(record.getSpec().getPublished())) {
return ServerResponse.badRequest()
.bodyValue(Map.of("message", "已发布的回复不可编辑"));
}
// Update AiCommentReply.spec.reply
return client.fetch(AiCommentReply.class, name)
.flatMap(latest -> {
latest.getSpec().setReply(newReply);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.flatMap(updatedRecord -> {
String replyName = updatedRecord.getSpec().getReplyName();
if (replyName != null && !replyName.isBlank()) {
// Reply extension exists, update its content too
return client.fetch(Reply.class, replyName)
.flatMap(reply -> {
reply.getSpec().setRaw(newReply);
reply.getSpec().setContent(newReply);
return client.update(reply);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(e -> e instanceof OptimisticLockingFailureException))
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)));
}
// Draft mode: no Reply extension, only update AiCommentReply
return Mono.just(updatedRecord);
})
.flatMap(finalRecord -> ServerResponse.ok().bodyValue(finalRecord));
})
.switchIfEmpty(ServerResponse.notFound().build());
});
}
}
@@ -59,5 +59,8 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "使用的AI角色名称")
private String personaName;
@Schema(description = "关联的Reply扩展名称,草稿模式下为空")
private String replyName;
}
}
@@ -37,5 +37,8 @@ public class AiPersona extends AbstractExtension {
@Schema(description = "是否为默认角色")
@JsonProperty("isDefault")
private Boolean isDefault;
@Schema(description = "排序优先级,数值越小越靠前")
private Integer priority;
}
}
@@ -4,8 +4,10 @@ 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;
@@ -148,6 +150,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
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);
@@ -155,6 +158,37 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
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);
@@ -4,9 +4,11 @@ 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;
@@ -159,6 +161,7 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
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);
@@ -166,6 +169,37 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
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);
@@ -36,7 +36,10 @@ public class AiReplyOrchestrator {
// 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<>();
// Value is the timestamp when the lock was acquired, used for leak detection
private final ConcurrentHashMap<String, Long> processingLocks = new ConcurrentHashMap<>();
private static final long LOCK_EXPIRY_MS = 2 * 60 * 1000L; // 2 minutes
public AiReplyOrchestrator(ContextExtractor contextExtractor,
PromptBuilder promptBuilder,
@@ -71,8 +74,11 @@ public class AiReplyOrchestrator {
String personaName) {
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
// Clean up stale locks before acquiring new one
cleanupStaleLocks();
// In-memory dedup: if already processing, skip immediately
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
if (processingLocks.putIfAbsent(lockKey, System.currentTimeMillis()) != null) {
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
return Mono.empty();
}
@@ -220,7 +226,7 @@ public class AiReplyOrchestrator {
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
context.commentId());
// Save the failed reply content, then retry
return updateRecord(replyRecord, aiReply, 0, "FAIL", false)
return updateRecord(replyRecord, aiReply, 0, "FAIL", false, null)
.then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed"));
}
return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName);
@@ -263,7 +269,7 @@ public class AiReplyOrchestrator {
} else {
log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}",
maxRetry, context.commentId(), reason);
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
return updateRecord(replyRecord, "", 0, "FAIL", false, null).then();
}
});
}
@@ -305,19 +311,36 @@ public class AiReplyOrchestrator {
}
/**
* Publish the reply and update the record to PASS + published=true.
* Publish the reply and update the record.
* When autoPublish=true: create Reply extension and save replyName.
* When autoPublish=false (draft mode): do NOT create Reply extension, only save AI reply content.
*/
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
AiCommentReply replyRecord, int score, String personaName) {
return isAutoPublishEnabled()
.flatMap(autoPublish -> {
return commentReplyPublisher.publishReply(
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName)
.flatMap(publishedReply -> {
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
})
.flatMap(updated -> Mono.empty());
if (autoPublish) {
// Auto publish: create Reply extension and save replyName
return commentReplyPublisher.publishReply(
context.commentId(), aiReply, context.postId(), context.replyTo(), true, personaName)
.flatMap(publishedReply -> {
String replyName = publishedReply.getMetadata().getName();
log.info("[Orchestrator] Reply published for: {}, replyName={}", context.commentId(), replyName);
return updateRecord(replyRecord, aiReply, score, "PASS", true, replyName);
})
.switchIfEmpty(Mono.defer(() -> {
// publishReply returned empty (dedup: AI reply already exists)
// Fallback to draft mode to avoid leaving record in PENDING state
log.warn("[Orchestrator] publishReply returned empty for {}, falling back to draft mode", context.commentId());
return updateRecord(replyRecord, aiReply, score, "PASS", false, null);
}))
.flatMap(updated -> Mono.empty());
} else {
// Draft mode: do NOT create Reply extension, only save AI reply content
log.info("[Orchestrator] Draft mode: saving reply content without creating Reply extension for: {}", context.commentId());
return updateRecord(replyRecord, aiReply, score, "PASS", false, null)
.flatMap(updated -> Mono.empty());
}
})
.then();
}
@@ -515,15 +538,17 @@ public class AiReplyOrchestrator {
}
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
int score, String status, boolean published) {
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
record.getMetadata().getName(), status, score, published);
int score, String status, boolean published,
String replyName) {
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}, replyName={}",
record.getMetadata().getName(), status, score, published, replyName);
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
.flatMap(latest -> {
latest.getSpec().setReply(reply);
latest.getSpec().setScore(score);
latest.getSpec().setStatus(status);
latest.getSpec().setPublished(published);
latest.getSpec().setReplyName(replyName);
return client.update(latest);
})
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
@@ -531,7 +556,24 @@ public class AiReplyOrchestrator {
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
record.getMetadata().getName()))
)
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
record.getMetadata().getName(), status, score, published));
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}, replyName={}",
record.getMetadata().getName(), status, score, published, replyName));
}
/**
* Clean up stale locks that have been held longer than LOCK_EXPIRY_MS.
* This prevents memory leaks in case of unexpected errors or cancellations
* that bypass the doFinally cleanup.
*/
private void cleanupStaleLocks() {
long now = System.currentTimeMillis();
processingLocks.entrySet().removeIf(entry -> {
long age = now - entry.getValue();
if (age > LOCK_EXPIRY_MS) {
log.warn("[Orchestrator] Removing stale lock: {} (held for {}ms)", entry.getKey(), age);
return true;
}
return false;
});
}
}
@@ -118,6 +118,7 @@ public class CommentReplyPublisher {
Map<String, String> ownerAnnotations = new HashMap<>();
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
// 使用Gravatar邮箱头像
if (email != null && !email.isBlank()) {
String gravatarUrl = generateGravatarUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
@@ -125,9 +126,16 @@ public class CommentReplyPublisher {
owner.setAnnotations(ownerAnnotations);
spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}",
parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations);
return client.create(reply)
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
displayName, parentCommentName, quoteReplyName))
.doOnSuccess(created -> {
var createdOwner = created.getSpec().getOwner();
log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}",
displayName, parentCommentName, quoteReplyName,
createdOwner != null ? createdOwner.getAnnotations() : "null");
})
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
});
}
@@ -142,10 +150,14 @@ public class CommentReplyPublisher {
private Mono<ResolvedPersona> resolvePersona(String personaName) {
if (personaName != null && !personaName.isBlank()) {
return client.fetch(AiPersona.class, personaName)
.map(p -> new ResolvedPersona(
p.getSpec().getDisplayName(),
p.getSpec().getEmail()
))
.map(p -> {
log.info("[Publisher] Resolved persona by name: {}, email={}",
personaName, p.getSpec().getEmail());
return new ResolvedPersona(
p.getSpec().getDisplayName(),
p.getSpec().getEmail()
);
})
.defaultIfEmpty(new ResolvedPersona("小回", ""));
}
// Find default persona
@@ -153,10 +165,14 @@ public class CommentReplyPublisher {
persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()),
null)
.next()
.map(p -> new ResolvedPersona(
p.getSpec().getDisplayName(),
p.getSpec().getEmail()
))
.map(p -> {
log.info("[Publisher] Resolved default persona: {}, email={}",
p.getMetadata().getName(), p.getSpec().getEmail());
return new ResolvedPersona(
p.getSpec().getDisplayName(),
p.getSpec().getEmail()
);
})
.defaultIfEmpty(new ResolvedPersona("小回", ""));
}
@@ -135,7 +135,7 @@ public class FilterService {
private String getCommenterEmail(Comment comment) {
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
var owner = comment.getSpec().getOwner();
if ("EMAIL".equals(owner.getKind())) {
if (Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())) {
var name = owner.getName();
return name != null ? name : "";
}
@@ -39,6 +39,18 @@ public class PromptBuilder {
private static final String PRESET_CONCISE = """
【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。
""";
private static final String PRESET_TECHNICAL = """
【技术解答型预设】你的回复应该侧重于技术解答,提供准确的技术信息和解决方案。使用专业术语但要解释清楚,必要时提供代码示例或步骤说明。保持逻辑清晰,分点阐述。
""";
private static final String PRESET_ENCOURAGING = """
【鼓励型预设】你的回复应该充满鼓励和正能量,认可评论者的观点和想法。多用肯定性语言,表达对评论者思考的赞赏。即使评论有不足,也要以建设性的方式指出,给予信心和动力。
""";
private static final String PRESET_EDUCATIONAL = """
【知识科普型预设】你的回复应该以科普的方式展开,将复杂概念用通俗易懂的语言解释。适当引用相关知识点,帮助评论者拓宽视野。使用类比和举例让内容更易理解,但避免过于学术化。
""";
private static final Map<String, String> PRESET_MAP = new LinkedHashMap<>();
@@ -47,6 +59,9 @@ public class PromptBuilder {
PRESET_MAP.put("professional", PRESET_PROFESSIONAL);
PRESET_MAP.put("humorous", PRESET_HUMOROUS);
PRESET_MAP.put("concise", PRESET_CONCISE);
PRESET_MAP.put("technical", PRESET_TECHNICAL);
PRESET_MAP.put("encouraging", PRESET_ENCOURAGING);
PRESET_MAP.put("educational", PRESET_EDUCATIONAL);
}
private static final String SAFETY_PROMPT = """
@@ -61,7 +76,12 @@ public class PromptBuilder {
{{safety_prompt}}
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
【语言要求】你必须使用与评论相同的语言回复。检测评论的语言特征:
- 如果评论包含中文字符(汉字),请用中文回复
- 如果评论包含日文假名(平假名/片假名),请用日文回复
- 如果评论包含韩文字符,请用韩文回复
- 如果评论主要是拉丁字母,请根据其语言特征(如英语、法语、西班牙语等)用相同语言回复
- 绝对不要用与评论不同的语言回复
请回复以下评论。注意:
- 回复长度应与评论长度匹配,简短问候简短回复
@@ -37,3 +37,33 @@ spec:
label: AI角色
help: 选择该页面使用的AI回复角色名称,留空使用默认角色
value: ""
---
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: comment-ai-autopilot-category-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: Category
formSchema:
- $formkit: text
name: comment-ai-autopilot.nxxy335.top/ai-persona
label: AI角色
help: 选择该分类下文章使用的AI回复角色名称,留空使用默认角色
value: ""
---
apiVersion: v1alpha1
kind: AnnotationSetting
metadata:
name: comment-ai-autopilot-tag-annotation-setting
spec:
targetRef:
group: content.halo.run
kind: Tag
formSchema:
- $formkit: text
name: comment-ai-autopilot.nxxy335.top/ai-persona
label: AI角色
help: 选择该标签下文章使用的AI回复角色名称,留空使用默认角色
value: ""
@@ -19,3 +19,9 @@ rules:
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
resources: ["*"]
verbs: ["*"]
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
resources: ["export"]
verbs: ["get"]
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
resources: ["import"]
verbs: ["create"]
+1 -1
View File
@@ -22,6 +22,6 @@ 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: "0.0.1-t5w8r3"
version: "0.0.0-ygkszvd"
pluginDependencies:
ai-foundation: "*"