feat: 草稿模式修复、回复编辑、预设扩展、多语言回复、文档全面更新
- 修复草稿模式下仍自动发布评论的Bug - 新增草稿模式下编辑AI回复内容功能 - 新增3个Prompt预设(技术解答型/鼓励型/知识科普型) - 新增多语言回复指令 - 新增日志页面时间范围筛选 - 新增AI角色排序功能 - 新增配置导入/导出功能 - 修复processingLocks内存泄漏 - 修复OptimisticLockingFailureException缺少重试 - 修复publishReply空Mono导致审核挂起 - 移除近7日回复趋势卡片 - 版本号更新至0.0.0-ygkszvd - 全面更新插件文档
This commit is contained in:
+436
-86
@@ -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"]
|
||||
|
||||
@@ -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: "*"
|
||||
|
||||
Reference in New Issue
Block a user