refactor: 全面优化完善 - ObjectMapper注入、索引优化、代码去重、Bug修复

This commit is contained in:
sunny-335
2026-06-18 19:42:30 +08:00
parent 7934d8c947
commit 77e3bd36c5
15 changed files with 399 additions and 407 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
} }
group 'top.nxxy335.commentaiautopilot' group 'top.nxxy335.commentaiautopilot'
version '1.0.0-beta.2' version '1.0.0-beta.3'
repositories { repositories {
mavenCentral() mavenCentral()
@@ -43,6 +43,10 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
.indexFunc(ext -> ext.getSpec().getPostId())); .indexFunc(ext -> ext.getSpec().getPostId()));
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class) indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
.indexFunc(ext -> ext.getSpec().getStatus())); .indexFunc(ext -> ext.getSpec().getStatus()));
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.sentiment", String.class)
.indexFunc(ext -> ext.getSpec().getSentiment()));
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.published", String.class)
.indexFunc(ext -> String.valueOf(ext.getSpec().getPublished())));
}); });
schemeManager.register(AiPersona.class); schemeManager.register(AiPersona.class);
@@ -8,7 +8,6 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Comment; 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.Reply;
import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ConfigMap;
@@ -16,6 +15,7 @@ import run.halo.app.extension.Metadata;
import run.halo.app.extension.GroupVersion; import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.index.query.Queries;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.PageRequestImpl;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
@@ -24,6 +24,8 @@ import top.nxxy335.commentaiautopilot.service.AiFoundationClient;
import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService; import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher; import top.nxxy335.commentaiautopilot.service.CommentReplyPublisher;
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -56,16 +58,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
private final AiFoundationClient aiFoundationClient; private final AiFoundationClient aiFoundationClient;
private final CommentReplyPublisher commentReplyPublisher; private final CommentReplyPublisher commentReplyPublisher;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final PersonaResolver personaResolver;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher) { public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, AiFoundationClient aiFoundationClient, CommentReplyPublisher commentReplyPublisher, ObjectMapper objectMapper, PersonaResolver personaResolver) {
this.client = client; this.client = client;
this.orchestrator = orchestrator; this.orchestrator = orchestrator;
this.cleanupService = cleanupService; this.cleanupService = cleanupService;
this.aiFoundationClient = aiFoundationClient; this.aiFoundationClient = aiFoundationClient;
this.commentReplyPublisher = commentReplyPublisher; this.commentReplyPublisher = commentReplyPublisher;
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
this.personaResolver = personaResolver;
} }
@Override @Override
@@ -133,19 +137,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
final Instant finalStartInstant = startInstant; final Instant finalStartInstant = startInstant;
final Instant finalEndInstant = endInstant; final Instant finalEndInstant = endInstant;
// Check if we need in-memory filtering (keyword, date range, status, or sentiment) // Check if we need in-memory filtering (keyword or date range)
boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null;
|| !statusFilter.isBlank() || !sentimentFilter.isBlank();
// Build server-side query for status and sentiment (indexed fields)
var listOptionsBuilder = ListOptions.builder();
if (!statusFilter.isBlank()) {
listOptionsBuilder.andQuery(Queries.equal("spec.status", statusFilter));
}
if (!sentimentFilter.isBlank()) {
listOptionsBuilder.andQuery(Queries.equal("spec.sentiment", sentimentFilter));
}
var listOptions = listOptionsBuilder.build();
if (needsMemoryFilter) { if (needsMemoryFilter) {
// Fall back to listAll + in-memory filter for complex queries // Fall back to listAll + in-memory filter for keyword/date queries
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) return client.listAll(AiCommentReply.class, listOptions, Sort.unsorted())
.collectList() .collectList()
.map(replies -> { .map(replies -> {
var filtered = replies.stream() var filtered = replies.stream()
.filter(r -> { .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()) { if (!keywordFilter.isBlank()) {
String reply = r.getSpec().getReply(); String reply = r.getSpec().getReply();
if (reply == null || !reply.contains(keywordFilter)) return false; if (reply == null || !reply.contains(keywordFilter)) return false;
@@ -184,13 +195,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.flatMap(result -> ServerResponse.ok().bodyValue(result)); .flatMap(result -> ServerResponse.ok().bodyValue(result));
} }
// No filters - use server-side pagination directly // No memory filters needed - use server-side pagination directly
Sort sort = "asc".equalsIgnoreCase(sortOrder) Sort sort = "asc".equalsIgnoreCase(sortOrder)
? Sort.by(Sort.Order.asc("metadata.creationTimestamp")) ? Sort.by(Sort.Order.asc("metadata.creationTimestamp"))
: Sort.by(Sort.Order.desc("metadata.creationTimestamp")); : Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
var listOptions = ListOptions.builder().build();
return client.listBy(AiCommentReply.class, listOptions, return client.listBy(AiCommentReply.class, listOptions,
PageRequestImpl.of(page - 1, size, sort)) PageRequestImpl.of(page - 1, size, sort))
.map(listResult -> { .map(listResult -> {
@@ -246,18 +255,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.next() .next()
.flatMap(persona -> { .flatMap(persona -> {
String email = persona.getSpec().getEmail(); String email = persona.getSpec().getEmail();
String avatarUrl = ""; String avatarUrl = GravatarUtil.generateUrl(email);
if (email != null && !email.isBlank()) {
try {
var digest = java.security.MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(java.nio.charset.StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
avatarUrl = "https://cn.cravatar.com/avatar/" + hexString;
} catch (Exception ignored) {}
}
return ServerResponse.ok().bodyValue(Map.of( return ServerResponse.ok().bodyValue(Map.of(
"name", persona.getSpec().getDisplayName(), "name", persona.getSpec().getDisplayName(),
"prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "", "prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "",
@@ -297,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
"comment", commentOwner, commentContent, commentTime, isCommentAi "comment", commentOwner, commentContent, commentTime, isCommentAi
); );
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted()) return client.list(Reply.class,
.filter(reply -> commentName.equals(reply.getSpec().getCommentName())) reply -> commentName.equals(reply.getSpec().getCommentName()),
null)
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp())) .sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
.map(reply -> { .map(reply -> {
var replyOwner = extractOwnerName(reply.getSpec().getOwner()); var replyOwner = extractOwnerName(reply.getSpec().getOwner());
@@ -642,7 +641,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.bodyValue(Map.of("message", "该评论已有AI回复记录")); .bodyValue(Map.of("message", "该评论已有AI回复记录"));
} }
// Read persona name from post annotations // Read persona name from post annotations
return getPersonaNameFromComment(commentName) return personaResolver.getPersonaNameFromComment(commentName)
.flatMap(personaName -> .flatMap(personaName ->
orchestrator.processComment(commentName, null, false, personaName) orchestrator.processComment(commentName, null, false, personaName)
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
@@ -669,7 +668,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
return ServerResponse.badRequest() return ServerResponse.badRequest()
.bodyValue(Map.of("message", "该回复已有AI对话记录")); .bodyValue(Map.of("message", "该回复已有AI对话记录"));
} }
return getPersonaNameFromComment(commentName) return personaResolver.getPersonaNameFromComment(commentName)
.flatMap(personaName -> .flatMap(personaName ->
orchestrator.processComment(commentName, replyName, true, personaName) orchestrator.processComment(commentName, replyName, true, personaName)
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
@@ -679,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
private Mono<String> getPersonaNameFromComment(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return Mono.justOrEmpty(null);
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.mapNotNull(post -> {
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
return null;
});
})
.defaultIfEmpty("");
}
private Mono<Reply> findReplyForRecord(AiCommentReply record) { private Mono<Reply> findReplyForRecord(AiCommentReply record) {
// First try using replyName if available // First try using replyName if available
String replyName = record.getSpec().getReplyName(); String replyName = record.getSpec().getReplyName();
@@ -748,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
) {} ) {}
private Mono<ServerResponse> listCommenters(ServerRequest request) { private Mono<ServerResponse> listCommenters(ServerRequest request) {
return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted()) return client.list(Comment.class, null, null)
.take(1000)
.collectList() .collectList()
.map(comments -> { .map(comments -> {
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
@@ -766,7 +741,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) { if (owner.getAnnotations() != null && owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO) != null) {
avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO); avatarUrl = owner.getAnnotations().get(Comment.CommentOwner.AVATAR_ANNO);
} else if (!email.isBlank()) { } else if (!email.isBlank()) {
avatarUrl = generateGravatarUrl(email); avatarUrl = GravatarUtil.generateUrl(email);
} }
result.add(new CommenterInfo(displayName, email, avatarUrl)); result.add(new CommenterInfo(displayName, email, avatarUrl));
} }
@@ -776,26 +751,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
.flatMap(commenters -> ServerResponse.ok().bodyValue(commenters)); .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) { private Mono<ServerResponse> triggerCleanup(ServerRequest request) {
return Mono.fromCallable(() -> { return cleanupService.getRetentionDays()
int retentionDays = cleanupService.getRetentionDays(); .flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays)
long deleted = cleanupService.executeCleanup(retentionDays); .map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays))
return Map.of("deletedCount", deleted, "retentionDays", retentionDays); )
})
.flatMap(result -> ServerResponse.ok().bodyValue(result)) .flatMap(result -> ServerResponse.ok().bodyValue(result))
.onErrorResume(e -> { .onErrorResume(e -> {
log.warn("Failed to trigger cleanup: {}", e.getMessage()); log.warn("Failed to trigger cleanup: {}", e.getMessage());
@@ -961,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
for (var personaData : personasList) { for (var personaData : personasList) {
importMono = importMono.then(Mono.defer(() -> { importMono = importMono.then(Mono.defer(() -> {
try { try {
var objectMapper = new com.fasterxml.jackson.databind.ObjectMapper();
var personaJson = objectMapper.writeValueAsString(personaData); var personaJson = objectMapper.writeValueAsString(personaData);
var persona = objectMapper.readValue(personaJson, AiPersona.class); var persona = objectMapper.readValue(personaJson, AiPersona.class);
var personaName = persona.getMetadata().getName(); var personaName = persona.getMetadata().getName();
@@ -1,31 +0,0 @@
package top.nxxy335.commentaiautopilot.listener;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import top.nxxy335.commentaiautopilot.extension.AiPersona;
@Component
@Slf4j
@RequiredArgsConstructor
public class AiPersonaReconciler implements Reconciler<Reconciler.Request> {
private final ReactiveExtensionClient client;
@Override
public Result reconcile(Request request) {
return new Result(false, null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new AiPersona())
.syncAllOnStart(false)
.build();
}
}
@@ -4,22 +4,18 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.scheduler.Schedulers; 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.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.ExtensionClient;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
@Component @Component
@Slf4j @Slf4j
@@ -28,31 +24,19 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client; private final ExtensionClient client;
private final AiReplyOrchestrator orchestrator; private final AiReplyOrchestrator orchestrator;
private final PersonaResolver personaResolver;
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed"; private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-"; private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona"; private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
// Record the time when this bean was created (plugin startup time) // Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now(); private final Instant pluginStartTime = Instant.now();
// In-memory dedup lock: prevents the same comment from being processed multiple times
// even if reconcile is triggered concurrently
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
@Override @Override
public Result reconcile(Request request) { public Result reconcile(Request request) {
var name = request.name(); var name = request.name();
// Acquire lock at the very beginning to prevent any concurrent processing
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
return Result.doNotRetry();
}
AtomicBoolean asyncStarted = new AtomicBoolean(false);
try {
client.fetch(Comment.class, name).ifPresent(comment -> { client.fetch(Comment.class, name).ifPresent(comment -> {
if (isProcessed(comment.getMetadata().getAnnotations())) { if (isProcessed(comment.getMetadata().getAnnotations())) {
return; return;
@@ -93,32 +77,18 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
client.update(comment); client.update(comment);
// Read persona name from the post's annotations // Read persona name from the post's annotations
String personaName = getPersonaNameFromComment(comment); String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
// Top-level comment → always trigger AI reply // Top-level comment → always trigger AI reply
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
asyncStarted.set(true);
orchestrator.processComment(name, null, false, personaName) orchestrator.processComment(name, null, false, personaName)
.subscribeOn(Schedulers.boundedElastic()) .subscribeOn(Schedulers.boundedElastic())
.doFinally(signal -> {
processingLocks.remove(name);
log.debug("[CommentReconciler] Released processing lock for: {}", name);
})
.subscribe( .subscribe(
null, null,
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e), e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name) () -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
); );
}); });
} catch (Exception e) {
log.error("[CommentReconciler] Error in reconcile for {}: {}", name, e.getMessage(), e);
} finally {
// Only release lock here if async processing was NOT started
// (async path releases lock in doFinally)
if (!asyncStarted.get()) {
processingLocks.remove(name);
}
}
return Result.doNotRetry(); return Result.doNotRetry();
} }
@@ -139,61 +109,6 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
return false; return false;
} }
/**
* Read persona name from the post's annotations associated with this comment.
*/
private String getPersonaNameFromComment(Comment comment) {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return null;
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.map(post -> {
// 1. 文章注解优先
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
// 2. 分类注解
var spec = post.getSpec();
if (spec != null && spec.getCategories() != null) {
for (String categoryName : spec.getCategories()) {
var cat = client.fetch(Category.class, categoryName).orElse(null);
if (cat != null) {
var catAnnotations = cat.getMetadata().getAnnotations();
if (catAnnotations != null) {
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
if (catPersona != null && !catPersona.isBlank()) {
return catPersona;
}
}
}
}
}
// 3. 标签注解
if (spec != null && spec.getTags() != null) {
for (String tagName : spec.getTags()) {
var tag = client.fetch(Tag.class, tagName).orElse(null);
if (tag != null) {
var tagAnnotations = tag.getMetadata().getAnnotations();
if (tagAnnotations != null) {
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
if (tagPersona != null && !tagPersona.isBlank()) {
return tagPersona;
}
}
}
}
}
return null;
})
.orElse(null);
}
private boolean isProcessed(Map<String, String> annotations) { private boolean isProcessed(Map<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION)); return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
} }
@@ -4,17 +4,15 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.scheduler.Schedulers; 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.Comment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Reply; 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.ExtensionClient;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
@@ -27,11 +25,11 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client; private final ExtensionClient client;
private final AiReplyOrchestrator orchestrator; private final AiReplyOrchestrator orchestrator;
private final PersonaResolver personaResolver;
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed"; private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-"; private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai"; private static final String AI_MARKER_ANNOTATION = "comment-ai-autopilot.nxxy335.top/is-ai";
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
// Record the time when this bean was created (plugin startup time) // Record the time when this bean was created (plugin startup time)
private final Instant pluginStartTime = Instant.now(); private final Instant pluginStartTime = Instant.now();
@@ -120,7 +118,9 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
client.update(reply); client.update(reply);
// Reply to AI → trigger AI reply (conversation continuation) // Reply to AI → trigger AI reply (conversation continuation)
String personaName = getPersonaNameFromComment(parentCommentName); String personaName = client.fetch(Comment.class, parentCommentName)
.map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment))
.orElse(null);
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName); log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
orchestrator.processComment(parentCommentName, name, true, personaName) orchestrator.processComment(parentCommentName, name, true, personaName)
.subscribeOn(Schedulers.boundedElastic()) .subscribeOn(Schedulers.boundedElastic())
@@ -154,65 +154,6 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
.orElse(false); .orElse(false);
} }
/**
* Read persona name from the post's annotations associated with the parent comment.
*/
private String getPersonaNameFromComment(String commentName) {
return client.fetch(Comment.class, commentName)
.map(comment -> {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return null;
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.map(post -> {
// 1. 文章注解优先
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
// 2. 分类注解
var spec = post.getSpec();
if (spec != null && spec.getCategories() != null) {
for (String categoryName : spec.getCategories()) {
var cat = client.fetch(Category.class, categoryName).orElse(null);
if (cat != null) {
var catAnnotations = cat.getMetadata().getAnnotations();
if (catAnnotations != null) {
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
if (catPersona != null && !catPersona.isBlank()) {
return catPersona;
}
}
}
}
}
// 3. 标签注解
if (spec != null && spec.getTags() != null) {
for (String tagName : spec.getTags()) {
var tag = client.fetch(Tag.class, tagName).orElse(null);
if (tag != null) {
var tagAnnotations = tag.getMetadata().getAnnotations();
if (tagAnnotations != null) {
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
if (tagPersona != null && !tagPersona.isBlank()) {
return tagPersona;
}
}
}
}
}
return null;
})
.orElse(null);
})
.orElse(null);
}
private boolean isProcessed(Map<String, String> annotations) { private boolean isProcessed(Map<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION)); return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
} }
@@ -17,6 +17,9 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
@Component @Component
@Slf4j @Slf4j
public class AiReplyCleanupService implements DisposableBean { public class AiReplyCleanupService implements DisposableBean {
@@ -27,9 +30,9 @@ public class AiReplyCleanupService implements DisposableBean {
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
public AiReplyCleanupService(ReactiveExtensionClient client) { public AiReplyCleanupService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
this.client = client; this.client = client;
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ai-reply-cleanup"); Thread t = new Thread(r, "ai-reply-cleanup");
t.setDaemon(true); t.setDaemon(true);
@@ -40,8 +43,25 @@ public class AiReplyCleanupService implements DisposableBean {
} }
public void dailyCleanup() { public void dailyCleanup() {
try { isCleanupEnabled()
Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .flatMap(enabled -> {
if (!Boolean.TRUE.equals(enabled)) {
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
return Mono.empty();
}
return getRetentionDays()
.flatMap(retentionDays -> executeCleanup(retentionDays)
.doOnNext(deleted -> log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays))
);
})
.subscribe(
null,
e -> log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e)
);
}
private Mono<Boolean> isCleanupEnabled() {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
var data = cm.getData(); var data = cm.getData();
if (data == null) return false; if (data == null) return false;
@@ -55,51 +75,35 @@ public class AiReplyCleanupService implements DisposableBean {
return true; return true;
} }
}) })
.defaultIfEmpty(true) .defaultIfEmpty(true);
.block();
if (!Boolean.TRUE.equals(enabled)) {
log.debug("[Cleanup] Auto cleanup is disabled, skipping");
return;
} }
int retentionDays = getRetentionDays(); public Mono<Long> executeCleanup(int retentionDays) {
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); Instant cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
var oldRecords = client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
.filter(r -> { .filter(r -> {
Instant created = r.getMetadata().getCreationTimestamp(); Instant created = r.getMetadata().getCreationTimestamp();
return created != null && created.isBefore(cutoff); return created != null && created.isBefore(cutoff);
}) })
.collectList() .collectList()
.block(); .flatMap(oldRecords -> {
if (oldRecords.isEmpty()) {
if (oldRecords == null || oldRecords.isEmpty()) { return Mono.just(0L);
return 0;
} }
return Flux.fromIterable(oldRecords)
long deleted = 0; .flatMap(record -> client.delete(record)
for (var record : oldRecords) { .thenReturn(1L)
try { .onErrorResume(e -> {
client.delete(record).block();
deleted++;
} catch (Exception e) {
log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage()); log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage());
} return Mono.just(0L);
} })
return deleted; )
.reduce(0L, Long::sum);
});
} }
public int getRetentionDays() { public Mono<Integer> getRetentionDays() {
try {
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
.mapNotNull(cm -> { .mapNotNull(cm -> {
var data = cm.getData(); var data = cm.getData();
@@ -114,11 +118,10 @@ public class AiReplyCleanupService implements DisposableBean {
} }
}) })
.defaultIfEmpty(30) .defaultIfEmpty(30)
.block(); .onErrorResume(e -> {
} catch (Exception e) {
log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage()); log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage());
return 30; return Mono.just(30);
} });
} }
@Override @Override
@@ -49,7 +49,8 @@ public class AiReplyOrchestrator {
CommentReplyPublisher commentReplyPublisher, CommentReplyPublisher commentReplyPublisher,
FilterService filterService, FilterService filterService,
RateLimitService rateLimitService, RateLimitService rateLimitService,
ReactiveExtensionClient client) { ReactiveExtensionClient client,
ObjectMapper objectMapper) {
this.contextExtractor = contextExtractor; this.contextExtractor = contextExtractor;
this.promptBuilder = promptBuilder; this.promptBuilder = promptBuilder;
this.aiReplyService = aiReplyService; this.aiReplyService = aiReplyService;
@@ -59,7 +60,7 @@ public class AiReplyOrchestrator {
this.filterService = filterService; this.filterService = filterService;
this.rateLimitService = rateLimitService; this.rateLimitService = rateLimitService;
this.client = client; this.client = client;
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
} }
/** /**
@@ -8,9 +8,8 @@ import run.halo.app.core.extension.content.Reply;
import run.halo.app.extension.Metadata; import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import top.nxxy335.commentaiautopilot.extension.AiPersona; import top.nxxy335.commentaiautopilot.extension.AiPersona;
import top.nxxy335.commentaiautopilot.util.GravatarUtil;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@@ -120,7 +119,7 @@ public class CommentReplyPublisher {
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
// 使用Gravatar邮箱头像 // 使用Gravatar邮箱头像
if (email != null && !email.isBlank()) { if (email != null && !email.isBlank()) {
String gravatarUrl = generateGravatarUrl(email); String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
} }
owner.setAnnotations(ownerAnnotations); owner.setAnnotations(ownerAnnotations);
@@ -181,22 +180,4 @@ public class CommentReplyPublisher {
private String generateReplyName() { private String generateReplyName() {
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8); return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
} }
/**
* Generate Gravatar URL from email address using SHA-256 hash.
*/
private String generateGravatarUrl(String email) {
try {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
return "https://cn.cravatar.com/avatar/" + hexString;
} catch (Exception e) {
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
return "";
}
}
} }
@@ -28,9 +28,9 @@ public class FilterService {
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled"; private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
private static final String GROUP_CONTENT = "content.halo.run"; private static final String GROUP_CONTENT = "content.halo.run";
public FilterService(ReactiveExtensionClient client) { public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) {
this.client = client; this.client = client;
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
} }
public Mono<Boolean> shouldProcess(Comment comment) { public Mono<Boolean> shouldProcess(Comment comment) {
@@ -0,0 +1,175 @@
package top.nxxy335.commentaiautopilot.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.content.Category;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ReactiveExtensionClient;
import reactor.core.publisher.Mono;
/**
* Shared service for resolving AI persona name from a comment's associated
* post/category/tag annotations.
*
* <p>Priority: Post annotation &gt; Category annotation &gt; Tag annotation
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class PersonaResolver {
private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona";
private final ReactiveExtensionClient reactiveClient;
/**
* Resolve persona name from a comment (reactive version).
* Reads the post's annotations, then falls back to category and tag annotations.
*
* @param commentName the Comment metadata.name
* @return the persona name, or empty string if none found
*/
public Mono<String> getPersonaNameFromComment(String commentName) {
return reactiveClient.fetch(Comment.class, commentName)
.flatMap(comment -> {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return Mono.just("");
}
String postName = subjectRef.getName();
return resolveFromPost(postName);
})
.defaultIfEmpty("");
}
private Mono<String> resolveFromPost(String postName) {
return reactiveClient.fetch(Post.class, postName)
.flatMap(post -> {
// 1. Post annotation takes priority
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return Mono.just(persona);
}
}
// 2. Category annotations
var spec = post.getSpec();
if (spec != null && spec.getCategories() != null) {
for (String categoryName : spec.getCategories()) {
var persona = resolveFromCategory(categoryName);
if (persona != null) return Mono.just(persona);
}
}
// 3. Tag annotations
if (spec != null && spec.getTags() != null) {
for (String tagName : spec.getTags()) {
var persona = resolveFromTag(tagName);
if (persona != null) return Mono.just(persona);
}
}
return Mono.just("");
})
.defaultIfEmpty("");
}
private String resolveFromCategory(String categoryName) {
// Use block() here because this is called from a Reconciler (sync context)
// For reactive context, the caller should use the reactive version
try {
return reactiveClient.fetch(Category.class, categoryName)
.mapNotNull(cat -> {
var catAnnotations = cat.getMetadata().getAnnotations();
if (catAnnotations != null) {
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
if (catPersona != null && !catPersona.isBlank()) {
return catPersona;
}
}
return null;
})
.block();
} catch (Exception e) {
return null;
}
}
private String resolveFromTag(String tagName) {
try {
return reactiveClient.fetch(Tag.class, tagName)
.mapNotNull(tag -> {
var tagAnnotations = tag.getMetadata().getAnnotations();
if (tagAnnotations != null) {
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
if (tagPersona != null && !tagPersona.isBlank()) {
return tagPersona;
}
}
return null;
})
.block();
} catch (Exception e) {
return null;
}
}
/**
* Resolve persona name from a comment using blocking ExtensionClient
* (for use in Reconciler sync context).
*/
public String getPersonaNameFromCommentBlocking(ExtensionClient client, Comment comment) {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return null;
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.map(post -> {
// 1. Post annotation
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
// 2. Category annotations
var spec = post.getSpec();
if (spec != null && spec.getCategories() != null) {
for (String categoryName : spec.getCategories()) {
var cat = client.fetch(Category.class, categoryName).orElse(null);
if (cat != null) {
var catAnnotations = cat.getMetadata().getAnnotations();
if (catAnnotations != null) {
String catPersona = catAnnotations.get(AI_PERSONA_ANNOTATION);
if (catPersona != null && !catPersona.isBlank()) {
return catPersona;
}
}
}
}
}
// 3. Tag annotations
if (spec != null && spec.getTags() != null) {
for (String tagName : spec.getTags()) {
var tag = client.fetch(Tag.class, tagName).orElse(null);
if (tag != null) {
var tagAnnotations = tag.getMetadata().getAnnotations();
if (tagAnnotations != null) {
String tagPersona = tagAnnotations.get(AI_PERSONA_ANNOTATION);
if (tagPersona != null && !tagPersona.isBlank()) {
return tagPersona;
}
}
}
}
}
return null;
})
.orElse(null);
}
}
@@ -20,9 +20,9 @@ public class PromptBuilder {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
public PromptBuilder(ReactiveExtensionClient client) { public PromptBuilder(ReactiveExtensionClient client, ObjectMapper objectMapper) {
this.client = client; this.client = client;
this.objectMapper = new ObjectMapper(); this.objectMapper = objectMapper;
} }
private static final String PRESET_FRIENDLY = """ private static final String PRESET_FRIENDLY = """
@@ -0,0 +1,41 @@
package top.nxxy335.commentaiautopilot.util;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* Utility for generating Gravatar/Cravatar avatar URLs from email addresses.
*/
@Slf4j
public class GravatarUtil {
private static final String CRAVATAR_BASE_URL = "https://cn.cravatar.com/avatar/";
private GravatarUtil() {}
/**
* Generate Cravatar URL from email address using SHA-256 hash.
*
* @param email the email address
* @return the avatar URL, or empty string if generation fails
*/
public static String generateUrl(String email) {
if (email == null || email.isBlank()) {
return "";
}
try {
var digest = MessageDigest.getInstance("SHA-256");
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
var hexString = new StringBuilder();
for (byte b : hashBytes) {
hexString.append(String.format("%02x", b));
}
return CRAVATAR_BASE_URL + hexString;
} catch (Exception e) {
log.error("Failed to generate Gravatar URL: {}", e.getMessage());
return "";
}
}
}
+1 -1
View File
@@ -30,4 +30,4 @@ spec:
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
settingName: "comment-ai-autopilot-settings" settingName: "comment-ai-autopilot-settings"
configMapName: "comment-ai-autopilot-configmap" configMapName: "comment-ai-autopilot-configmap"
version: "1.0.0-beta.2" version: "1.0.0-beta.3"
+4 -1
View File
@@ -863,7 +863,10 @@ const fetchSettings = async () => {
if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) } if (Object.keys(prompt).length) { settings.prompt.customPromptTemplate = (prompt.customPromptTemplate as string) || ""; const ep = prompt.enabledPresets; settings.prompt.enabledPresets = Array.isArray(ep) ? ep : (typeof ep === 'string' ? (ep as string).split(",").map((s: string) => s.trim()).filter(Boolean) : []) }
if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 } if (Object.keys(cleanup).length) { settings.cleanup.cleanupEnabled = cleanup.cleanupEnabled !== false; settings.cleanup.retentionDays = (cleanup.retentionDays as number) || 30 }
} }
} catch (e) { console.error("Failed to fetch settings", e) } } catch (e) {
console.error("Failed to fetch settings", e)
Toast.error("加载设置失败,请刷新页面重试")
}
finally { finally {
loading.value = false loading.value = false
// Update snapshot after fetch to reset unsaved indicator // Update snapshot after fetch to reset unsaved indicator