From 77e3bd36c5ae9e7e19b9e2d8d6602d94c27ed71c Mon Sep 17 00:00:00 2001 From: sunny-335 Date: Thu, 18 Jun 2026 19:42:30 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=85=A8=E9=9D=A2=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=AE=8C=E5=96=84=20-=20ObjectMapper=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E3=80=81=E7=B4=A2=E5=BC=95=E4=BC=98=E5=8C=96=E3=80=81?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=8E=BB=E9=87=8D=E3=80=81Bug=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../CommentAiAutopilotPlugin.java | 4 + .../endpoint/CommentAiAutopilotEndpoint.java | 111 ++++------- .../listener/AiPersonaReconciler.java | 31 --- .../listener/CommentReconciler.java | 187 +++++------------- .../listener/ReplyReconciler.java | 69 +------ .../service/AiReplyCleanupService.java | 143 +++++++------- .../service/AiReplyOrchestrator.java | 5 +- .../service/CommentReplyPublisher.java | 23 +-- .../service/FilterService.java | 4 +- .../service/PersonaResolver.java | 175 ++++++++++++++++ .../service/PromptBuilder.java | 4 +- .../commentaiautopilot/util/GravatarUtil.java | 41 ++++ src/main/resources/plugin.yaml | 2 +- ui/src/views/SettingsView.vue | 5 +- 15 files changed, 399 insertions(+), 407 deletions(-) delete mode 100644 src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java create mode 100644 src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java create mode 100644 src/main/java/top/nxxy335/commentaiautopilot/util/GravatarUtil.java diff --git a/build.gradle b/build.gradle index 5fbbe66..8b2297e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '1.0.0-beta.2' +version '1.0.0-beta.3' repositories { mavenCentral() diff --git a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java index d8de177..f924091 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java @@ -43,6 +43,10 @@ public class CommentAiAutopilotPlugin extends BasePlugin { .indexFunc(ext -> ext.getSpec().getPostId())); indexSpecs.add(IndexSpecs.single("spec.status", String.class) .indexFunc(ext -> ext.getSpec().getStatus())); + indexSpecs.add(IndexSpecs.single("spec.sentiment", String.class) + .indexFunc(ext -> ext.getSpec().getSentiment())); + indexSpecs.add(IndexSpecs.single("spec.published", String.class) + .indexFunc(ext -> String.valueOf(ext.getSpec().getPublished()))); }); schemeManager.register(AiPersona.class); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index 86c4a10..3dcd71a 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -8,7 +8,6 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; 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.endpoint.CustomEndpoint; 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.ListOptions; 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.PageRequestImpl; 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.AiReplyOrchestrator; 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.ObjectMapper; @@ -56,16 +58,18 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private final AiFoundationClient aiFoundationClient; private final CommentReplyPublisher commentReplyPublisher; private final ObjectMapper objectMapper; + private final PersonaResolver personaResolver; 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.orchestrator = orchestrator; this.cleanupService = cleanupService; this.aiFoundationClient = aiFoundationClient; this.commentReplyPublisher = commentReplyPublisher; - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; + this.personaResolver = personaResolver; } @Override @@ -133,19 +137,26 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { final Instant finalStartInstant = startInstant; final Instant finalEndInstant = endInstant; - // Check if we need in-memory filtering (keyword, date range, status, or sentiment) - boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null - || !statusFilter.isBlank() || !sentimentFilter.isBlank(); + // Check if we need in-memory filtering (keyword or date range) + boolean needsMemoryFilter = !keywordFilter.isBlank() || finalStartInstant != null || finalEndInstant != null; + + // 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) { - // Fall back to listAll + in-memory filter for complex queries - return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) + // Fall back to listAll + in-memory filter for keyword/date queries + return client.listAll(AiCommentReply.class, listOptions, 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; @@ -184,13 +195,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .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.by(Sort.Order.asc("metadata.creationTimestamp")) : Sort.by(Sort.Order.desc("metadata.creationTimestamp")); - var listOptions = ListOptions.builder().build(); - return client.listBy(AiCommentReply.class, listOptions, PageRequestImpl.of(page - 1, size, sort)) .map(listResult -> { @@ -246,18 +255,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .next() .flatMap(persona -> { String email = persona.getSpec().getEmail(); - String avatarUrl = ""; - 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) {} - } + String avatarUrl = GravatarUtil.generateUrl(email); return ServerResponse.ok().bodyValue(Map.of( "name", persona.getSpec().getDisplayName(), "prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "", @@ -297,8 +295,9 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { "comment", commentOwner, commentContent, commentTime, isCommentAi ); - return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted()) - .filter(reply -> commentName.equals(reply.getSpec().getCommentName())) + return client.list(Reply.class, + reply -> commentName.equals(reply.getSpec().getCommentName()), + null) .sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp())) .map(reply -> { var replyOwner = extractOwnerName(reply.getSpec().getOwner()); @@ -642,7 +641,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .bodyValue(Map.of("message", "该评论已有AI回复记录")); } // Read persona name from post annotations - return getPersonaNameFromComment(commentName) + return personaResolver.getPersonaNameFromComment(commentName) .flatMap(personaName -> orchestrator.processComment(commentName, null, false, personaName) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))) @@ -669,7 +668,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return ServerResponse.badRequest() .bodyValue(Map.of("message", "该回复已有AI对话记录")); } - return getPersonaNameFromComment(commentName) + return personaResolver.getPersonaNameFromComment(commentName) .flatMap(personaName -> orchestrator.processComment(commentName, replyName, true, personaName) .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))) @@ -679,31 +678,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .switchIfEmpty(ServerResponse.notFound().build()); } - private static final String AI_PERSONA_ANNOTATION = "comment-ai-autopilot.nxxy335.top/ai-persona"; - - private Mono 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 findReplyForRecord(AiCommentReply record) { // First try using replyName if available String replyName = record.getSpec().getReplyName(); @@ -748,7 +722,8 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { ) {} private Mono listCommenters(ServerRequest request) { - return client.listAll(Comment.class, ListOptions.builder().build(), Sort.unsorted()) + return client.list(Comment.class, null, null) + .take(1000) .collectList() .map(comments -> { Set seen = new HashSet<>(); @@ -766,7 +741,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { 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); + avatarUrl = GravatarUtil.generateUrl(email); } result.add(new CommenterInfo(displayName, email, avatarUrl)); } @@ -776,26 +751,11 @@ 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 triggerCleanup(ServerRequest request) { - return Mono.fromCallable(() -> { - int retentionDays = cleanupService.getRetentionDays(); - long deleted = cleanupService.executeCleanup(retentionDays); - return Map.of("deletedCount", deleted, "retentionDays", retentionDays); - }) + return cleanupService.getRetentionDays() + .flatMap(retentionDays -> cleanupService.executeCleanup(retentionDays) + .map(deleted -> Map.of("deletedCount", deleted, "retentionDays", retentionDays)) + ) .flatMap(result -> ServerResponse.ok().bodyValue(result)) .onErrorResume(e -> { log.warn("Failed to trigger cleanup: {}", e.getMessage()); @@ -961,7 +921,6 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { 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(); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java deleted file mode 100644 index 3cde27a..0000000 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java +++ /dev/null @@ -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 { - - 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(); - } -} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java index d0c0c7d..d4e2f31 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java @@ -4,22 +4,18 @@ 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; import run.halo.app.extension.controller.Reconciler; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; +import top.nxxy335.commentaiautopilot.service.PersonaResolver; import java.time.Instant; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicBoolean; @Component @Slf4j @@ -28,97 +24,71 @@ public class CommentReconciler implements Reconciler { private final ExtensionClient client; private final AiReplyOrchestrator orchestrator; + private final PersonaResolver personaResolver; 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_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) 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 processingLocks = new ConcurrentHashMap<>(); - @Override public Result reconcile(Request request) { 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(); - } + client.fetch(Comment.class, name).ifPresent(comment -> { + if (isProcessed(comment.getMetadata().getAnnotations())) { + return; + } - AtomicBoolean asyncStarted = new AtomicBoolean(false); - try { - client.fetch(Comment.class, name).ifPresent(comment -> { - if (isProcessed(comment.getMetadata().getAnnotations())) { - return; - } - - // Skip comments created before plugin startup (historical comments) - var creationTime = comment.getMetadata().getCreationTimestamp(); - if (creationTime != null && creationTime.isBefore(pluginStartTime)) { - log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name); - markProcessed(comment); - client.update(comment); - return; - } - - // Skip comments from AI persona itself - if (isAiComment(comment)) { - markProcessed(comment); - client.update(comment); - return; - } - - // Dedup: check if we already have an AiCommentReply record for this comment - boolean alreadyHasRecord = !client.list(AiCommentReply.class, - record -> name.equals(record.getSpec().getCommentId()) - && !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()), - null) - .isEmpty(); - - if (alreadyHasRecord) { - log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name); - markProcessed(comment); - client.update(comment); - return; - } - - // Mark as processed first to avoid re-processing + // Skip comments created before plugin startup (historical comments) + var creationTime = comment.getMetadata().getCreationTimestamp(); + if (creationTime != null && creationTime.isBefore(pluginStartTime)) { + log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name); markProcessed(comment); client.update(comment); - - // Read persona name from the post's annotations - String personaName = getPersonaNameFromComment(comment); - - // Top-level comment → always trigger AI reply - log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); - asyncStarted.set(true); - orchestrator.processComment(name, null, false, personaName) - .subscribeOn(Schedulers.boundedElastic()) - .doFinally(signal -> { - processingLocks.remove(name); - log.debug("[CommentReconciler] Released processing lock for: {}", name); - }) - .subscribe( - null, - e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e), - () -> 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; } - } + + // Skip comments from AI persona itself + if (isAiComment(comment)) { + markProcessed(comment); + client.update(comment); + return; + } + + // Dedup: check if we already have an AiCommentReply record for this comment + boolean alreadyHasRecord = !client.list(AiCommentReply.class, + record -> name.equals(record.getSpec().getCommentId()) + && !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()), + null) + .isEmpty(); + + if (alreadyHasRecord) { + log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name); + markProcessed(comment); + client.update(comment); + return; + } + + // Mark as processed first to avoid re-processing + markProcessed(comment); + client.update(comment); + + // Read persona name from the post's annotations + String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment); + + // Top-level comment → always trigger AI reply + log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); + orchestrator.processComment(name, null, false, personaName) + .subscribeOn(Schedulers.boundedElastic()) + .subscribe( + null, + e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e), + () -> log.info("[CommentReconciler] Processing completed for comment: {}", name) + ); + }); return Result.doNotRetry(); } @@ -139,61 +109,6 @@ public class CommentReconciler implements Reconciler { 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 annotations) { return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION)); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java index 6d23e6e..268ea73 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java @@ -4,17 +4,15 @@ 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; import run.halo.app.extension.controller.Reconciler; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; +import top.nxxy335.commentaiautopilot.service.PersonaResolver; import java.time.Instant; import java.util.HashMap; @@ -27,11 +25,11 @@ public class ReplyReconciler implements Reconciler { private final ExtensionClient client; private final AiReplyOrchestrator orchestrator; + private final PersonaResolver personaResolver; 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_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) private final Instant pluginStartTime = Instant.now(); @@ -120,7 +118,9 @@ public class ReplyReconciler implements Reconciler { client.update(reply); // 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); orchestrator.processComment(parentCommentName, name, true, personaName) .subscribeOn(Schedulers.boundedElastic()) @@ -154,65 +154,6 @@ public class ReplyReconciler implements Reconciler { .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 annotations) { return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION)); } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java index ae1456c..cb989ed 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java @@ -17,6 +17,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Flux; + @Component @Slf4j 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"; - public AiReplyCleanupService(ReactiveExtensionClient client) { + public AiReplyCleanupService(ReactiveExtensionClient client, ObjectMapper objectMapper) { this.client = client; - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "ai-reply-cleanup"); t.setDaemon(true); @@ -40,85 +43,85 @@ public class AiReplyCleanupService implements DisposableBean { } public void dailyCleanup() { - try { - Boolean enabled = client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return false; - String cleanupJson = data.get("cleanup"); - if (cleanupJson == null || cleanupJson.isBlank()) return true; - try { - JsonNode node = objectMapper.readTree(cleanupJson); - return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true); - } catch (Exception e) { - log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage()); - return true; - } - }) - .defaultIfEmpty(true) - .block(); - - if (!Boolean.TRUE.equals(enabled)) { - log.debug("[Cleanup] Auto cleanup is disabled, skipping"); - return; - } - - int retentionDays = getRetentionDays(); - long deleted = executeCleanup(retentionDays); - log.info("[Cleanup] Auto cleanup completed, deleted {} records older than {} days", deleted, retentionDays); - } catch (Exception e) { - log.error("[Cleanup] Error during daily cleanup: {}", e.getMessage(), e); - } + isCleanupEnabled() + .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) + ); } - public long executeCleanup(int retentionDays) { + private Mono isCleanupEnabled() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return false; + String cleanupJson = data.get("cleanup"); + if (cleanupJson == null || cleanupJson.isBlank()) return true; + try { + JsonNode node = objectMapper.readTree(cleanupJson); + return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true); + } catch (Exception e) { + log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage()); + return true; + } + }) + .defaultIfEmpty(true); + } + + public Mono executeCleanup(int retentionDays) { 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 -> { Instant created = r.getMetadata().getCreationTimestamp(); return created != null && created.isBefore(cutoff); }) .collectList() - .block(); - - if (oldRecords == null || oldRecords.isEmpty()) { - return 0; - } - - long deleted = 0; - for (var record : oldRecords) { - try { - client.delete(record).block(); - deleted++; - } catch (Exception e) { - log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage()); - } - } - return deleted; + .flatMap(oldRecords -> { + if (oldRecords.isEmpty()) { + return Mono.just(0L); + } + return Flux.fromIterable(oldRecords) + .flatMap(record -> client.delete(record) + .thenReturn(1L) + .onErrorResume(e -> { + log.warn("[Cleanup] Failed to delete record {}: {}", record.getMetadata().getName(), e.getMessage()); + return Mono.just(0L); + }) + ) + .reduce(0L, Long::sum); + }); } - public int getRetentionDays() { - try { - return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return 30; - String cleanupJson = data.get("cleanup"); - if (cleanupJson == null || cleanupJson.isBlank()) return 30; - try { - JsonNode node = objectMapper.readTree(cleanupJson); - return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30; - } catch (Exception e) { - return 30; - } - }) - .defaultIfEmpty(30) - .block(); - } catch (Exception e) { - log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage()); - return 30; - } + public Mono getRetentionDays() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return 30; + String cleanupJson = data.get("cleanup"); + if (cleanupJson == null || cleanupJson.isBlank()) return 30; + try { + JsonNode node = objectMapper.readTree(cleanupJson); + return node.has("retentionDays") ? node.get("retentionDays").asInt(30) : 30; + } catch (Exception e) { + return 30; + } + }) + .defaultIfEmpty(30) + .onErrorResume(e -> { + log.warn("[Cleanup] Failed to read retentionDays config: {}", e.getMessage()); + return Mono.just(30); + }); } @Override diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index 93d3b57..a3945ae 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -49,7 +49,8 @@ public class AiReplyOrchestrator { CommentReplyPublisher commentReplyPublisher, FilterService filterService, RateLimitService rateLimitService, - ReactiveExtensionClient client) { + ReactiveExtensionClient client, + ObjectMapper objectMapper) { this.contextExtractor = contextExtractor; this.promptBuilder = promptBuilder; this.aiReplyService = aiReplyService; @@ -59,7 +60,7 @@ public class AiReplyOrchestrator { this.filterService = filterService; this.rateLimitService = rateLimitService; this.client = client; - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; } /** diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java index f49b742..82428d9 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java @@ -8,9 +8,8 @@ import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; 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.util.HashMap; import java.util.Map; @@ -120,7 +119,7 @@ public class CommentReplyPublisher { ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); // 使用Gravatar邮箱头像 if (email != null && !email.isBlank()) { - String gravatarUrl = generateGravatarUrl(email); + String gravatarUrl = GravatarUtil.generateUrl(email); ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); } owner.setAnnotations(ownerAnnotations); @@ -181,22 +180,4 @@ public class CommentReplyPublisher { private String generateReplyName() { 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 ""; - } - } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java index 3a345a4..c6c7cb2 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java @@ -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 GROUP_CONTENT = "content.halo.run"; - public FilterService(ReactiveExtensionClient client) { + public FilterService(ReactiveExtensionClient client, ObjectMapper objectMapper) { this.client = client; - this.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; } public Mono shouldProcess(Comment comment) { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java new file mode 100644 index 0000000..6d3128a --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PersonaResolver.java @@ -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. + * + *

Priority: Post annotation > Category annotation > 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 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 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); + } +} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index 773682d..07938c0 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -20,9 +20,9 @@ public class PromptBuilder { private final ObjectMapper objectMapper; 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.objectMapper = new ObjectMapper(); + this.objectMapper = objectMapper; } private static final String PRESET_FRIENDLY = """ diff --git a/src/main/java/top/nxxy335/commentaiautopilot/util/GravatarUtil.java b/src/main/java/top/nxxy335/commentaiautopilot/util/GravatarUtil.java new file mode 100644 index 0000000..26787fe --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/util/GravatarUtil.java @@ -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 ""; + } + } +} diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index e2b96f1..a66e927 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -30,4 +30,4 @@ 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: "1.0.0-beta.2" + version: "1.0.0-beta.3" diff --git a/ui/src/views/SettingsView.vue b/ui/src/views/SettingsView.vue index 705a070..82f3cfd 100644 --- a/ui/src/views/SettingsView.vue +++ b/ui/src/views/SettingsView.vue @@ -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(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 { loading.value = false // Update snapshot after fetch to reset unsaved indicator