diff --git a/README.md b/README.md index 0143b4f..47d5131 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。 -> **当前版本**: v1.0.0-beta.1 +> **当前版本**: v1.0.0-beta.2 ## 功能特性 @@ -70,6 +70,26 @@ pnpm dev ## 更新日志 +### v1.0.0-beta.2 + +**改进** + +- 通过 Halo 官方推荐的 `ExtensionGetter` 获取 AI Foundation 的 `AiModelService`,替换原先的跨 ClassLoader 反射调用方式 +- 在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系 +- 新增 `store.halo.run/recommended-apps` 注解,安装后可在应用市场推荐安装 AI Foundation 插件 +- 情感分析和内容审核改用 AI Foundation 结构化输出(`OutputSpec.choice`),分类更可靠 +- AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误 +- AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文 +- 优化 AI 自审核评分机制:改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分,替代原先的二值评分 +- 精简仪表盘:移除情感分布、近7日回复趋势、平均审核评分三个卡片,界面更简洁 +- 优化设置页面布局:按钮统一排版并添加图标,侧边栏新增"未保存"状态指示器 +- 优化日志页面评分显示:评分增加等级标签(优秀/良好/一般/较差) + +**Bug 修复** + +- 修复 RateLimitService 清理线程在插件停止时未关闭导致线程泄漏 +- 修复内容审核提示词要求"重新生成"但代码未使用重新生成内容的问题 + ### v1.0.0-beta.1 **新功能** diff --git a/build.gradle b/build.gradle index f56f01f..10878a4 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '1.0.0-beta.1' +version '1.0.0-beta.2-kx7m2p' repositories { mavenCentral() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 785c1c6..21d4d24 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,30 @@ # 更新日志 +## v1.0.0-beta.2 + +> 2026-06-17 + +### 改进 + +- **改用 ExtensionGetter 集成 AI Foundation**:通过 Halo 官方推荐的 `ExtensionGetter.getEnabledExtension(AiModelService.class)` 获取 AI 服务,替换原先的跨 ClassLoader 反射调用方式([Issue #1](https://github.com/sunny-335/plugin-comment-ai-autopilot/issues/1)) +- **声明插件依赖**:在 `plugin.yaml` 中声明可选插件依赖 `ai-foundation?: "*"`,建立正确的插件依赖关系,插件在未安装 AI Foundation 时仍可正常加载 +- **应用市场推荐**:新增 `store.halo.run/recommended-apps` 注解,安装本插件后可在应用市场推荐安装 AI Foundation 插件 +- **使用结构化输出**:情感分析和内容审核改用 AI Foundation 的 `OutputSpec.choice` 结构化输出,替换原先的字符串匹配解析,分类更可靠 +- **使用 GenerateTextRequest**:AI 调用改用 `GenerateTextRequest` 并设置 `maxRetries=2`,由 SDK 自动重试瞬时错误 +- **简化 languageModel 调用**:直接传递 modelName 参数,由 SDK 处理空值(使用默认模型),无需手动判断 +- **多轮对话上下文**:AI 对话续接时自动获取之前的回复历史并注入到 Prompt 中,AI 能更好地理解对话上下文 +- **优化 AI 自审核评分机制**:审核改为两阶段评估(安全检查 + 质量评分 1-5 分),评分映射到 0-100 分(0/30/50/70/85/100),替代原先的二值评分(0/100),评分更有区分度 +- **精简仪表盘**:移除情感分布、近7日回复趋势、平均审核评分三个卡片,保留核心的回复概览和快捷操作,界面更简洁 +- **优化设置页面布局**:页面头部按钮使用 VSpace 统一排版并添加图标;侧边栏保存卡片高亮显示,新增"未保存"状态指示器;保存/重置按钮添加图标 +- **优化日志页面评分显示**:评分增加等级标签(优秀/良好/一般/较差),更直观 + +### Bug 修复 + +- **修复 RateLimitService 线程泄漏**:清理线程未在插件停止时关闭,实现 `DisposableBean` 正确释放资源 +- **修复 ReviewService 提示词不匹配**:审核提示词要求"重新生成"但代码未使用重新生成的内容,移除误导性指令 + +--- + ## v1.0.0-beta.1 > 2026-06-16 diff --git a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java index dc63c48..86c4a10 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -216,100 +216,25 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } private Mono getStats(ServerRequest request) { - String range = request.queryParam("range").orElse("7"); - return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted()) .collectList() - .map(allReplies -> { - // 根据 range 计算截止时间 - ZoneId zoneId = ZoneId.systemDefault(); - LocalDate today = LocalDate.now(zoneId); - Instant cutoffInstant; - int trendDays; - - if ("all".equals(range)) { - cutoffInstant = null; // 不做时间过滤 - trendDays = 30; // "all" 时趋势也展示最近30天 - } else { - int days = Integer.parseInt(range); - cutoffInstant = today.minusDays(days).atStartOfDay(zoneId).toInstant(); - trendDays = days; - } - - // 根据 range 过滤记录 - List replies; - if (cutoffInstant != null) { - replies = allReplies.stream() - .filter(r -> { - Instant ts = r.getMetadata().getCreationTimestamp(); - return ts != null && !ts.isBefore(cutoffInstant); - }) - .toList(); - } else { - replies = allReplies; - } - + .map(replies -> { long total = replies.size(); long passCount = replies.stream() .filter(r -> "PASS".equals(r.getSpec().getStatus())).count(); long failCount = replies.stream() .filter(r -> "FAIL".equals(r.getSpec().getStatus())).count(); - double avgScore = replies.stream() - .filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0) - .mapToInt(r -> r.getSpec().getScore()) - .average().orElse(0.0); long reviewingCount = replies.stream() .filter(r -> "PASS".equals(r.getSpec().getStatus()) && !Boolean.TRUE.equals(r.getSpec().getPublished())) .count(); - Map sentimentDistribution = new HashMap<>(); - sentimentDistribution.put("POSITIVE", 0L); - sentimentDistribution.put("NEUTRAL", 0L); - sentimentDistribution.put("NEGATIVE", 0L); - sentimentDistribution.put("UNKNOWN", 0L); - for (var r : replies) { - String sentiment = r.getSpec().getSentiment(); - if (sentiment == null || sentiment.isBlank()) { - sentimentDistribution.merge("UNKNOWN", 1L, Long::sum); - } else { - sentimentDistribution.merge(sentiment, 1L, Long::sum); - } - } - - // 计算 dailyTrend - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - Map dailyMap = new HashMap<>(); - for (int i = 0; i < trendDays; i++) { - dailyMap.put(today.minusDays(i), 0L); - } - for (var r : replies) { - Instant timestamp = r.getMetadata().getCreationTimestamp(); - if (timestamp != null) { - try { - LocalDate date = timestamp.atZone(zoneId).toLocalDate(); - if (dailyMap.containsKey(date)) { - dailyMap.merge(date, 1L, Long::sum); - } - } catch (Exception ignored) { - } - } - } - List dailyTrend = new ArrayList<>(); - for (int i = 0; i < trendDays; i++) { - LocalDate date = today.minusDays(i); - dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date))); - } - - return new StatsResponse(total, passCount, failCount, avgScore, - reviewingCount, sentimentDistribution, dailyTrend); + return new StatsResponse(total, passCount, failCount, reviewingCount); }) .onErrorResume(e -> { log.warn("Failed to fetch stats: {}", e.getMessage()); - return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L, - Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L), - List.of())); + return Mono.just(new StatsResponse(0, 0, 0, 0)); }) .flatMap(stats -> ServerResponse.ok().bodyValue(stats)); } @@ -346,16 +271,11 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { ))); } - public record DailyCount(String date, long count) {} - public record StatsResponse( long total, long passCount, long failCount, - double avgScore, - long reviewingCount, - Map sentimentDistribution, - List dailyTrend + long reviewingCount ) {} public record PersonaResponse( diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java index c349d1f..fe8bce0 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiFoundationClient.java @@ -1,85 +1,58 @@ package top.nxxy335.commentaiautopilot.service; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.Plugin; -import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.aifoundation.AiModelService; +import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.GenerateTextResult; +import run.halo.aifoundation.schema.OutputSpec; +import run.halo.app.plugin.extensionpoint.ExtensionGetter; -import java.lang.reflect.Method; -import java.util.Map; +import java.util.List; /** - * AI Foundation client that uses runtime class loading and reflection - * to call the AI Foundation plugin's AiModelService. + * AI Foundation client that uses Halo's {@link ExtensionGetter} to obtain the + * {@link AiModelService} extension provided by the ai-foundation plugin. *

- * This approach avoids classloader identity issues by loading AiModelService - * from ai-foundation's own classloader, so that Spring's getBeansOfType() - * can correctly match the implementation bean. + * This is the recommended way to integrate with AI Foundation, see + * dev guide. *

- * No @ConditionalOnClass or pluginDependencies needed. - * Always registered as a bean; availability is checked at runtime. + * Requires the following declaration in plugin.yaml: + *

+ * spec:
+ *   pluginDependencies:
+ *     ai-foundation?: "*"
+ * 
+ * The dependency is optional, so the plugin still loads when AI Foundation is + * not installed; availability is checked at runtime and all calls return empty + * in that case. */ @Slf4j @Component public class AiFoundationClient { - private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation"; - private static final String AI_MODEL_SERVICE_CLASS = "run.halo.aifoundation.AiModelService"; + private final ExtensionGetter extensionGetter; - private final ReactiveExtensionClient client; - private final ApplicationContext applicationContext; - - public AiFoundationClient(ReactiveExtensionClient client, ApplicationContext applicationContext) { - this.client = client; - this.applicationContext = applicationContext; + public AiFoundationClient(ExtensionGetter extensionGetter) { + this.extensionGetter = extensionGetter; } /** * Call AI Foundation to generate a chat response using the specified model. + * Uses {@link GenerateTextRequest} with {@code maxRetries=2} so that + * transient model errors are retried by the SDK. * * @param prompt the prompt text * @param modelName the AiModel metadata.name, null or blank to use default model * @return the generated text, or empty if AI Foundation is unavailable */ public Mono chat(String prompt, String modelName) { - return isAiFoundationEnabled() - .flatMap(enabled -> { - if (!enabled) { - log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply"); - return Mono.empty(); - } - return doChat(prompt, modelName); - }); - } - - /** - * Check if AI Foundation is available: plugin installed, enabled, and AiModelService bean found. - */ - public Mono isAvailable() { - return isAiFoundationEnabled() - .flatMap(enabled -> { - if (!enabled) return Mono.just(false); - return findAiModelService().hasElement(); - }); - } - - private Mono isAiFoundationEnabled() { - return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME) - .map(plugin -> plugin.getSpec().getEnabled()) - .defaultIfEmpty(false) - .onErrorResume(e -> { - log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage()); - return Mono.just(false); - }); - } - - private Mono doChat(String prompt, String modelName) { - return findAiModelService() - .flatMap(service -> invokeLanguageModel(service, modelName) - .flatMap(model -> invokeGenerateText(model, prompt)) - ) + return aiModelService() + .flatMap(service -> service.languageModel(modelName != null ? modelName : "") + .flatMap(model -> model.generateText( + GenerateTextRequest.builder().prompt(prompt).maxRetries(2).build())) + .map(GenerateTextResult::getText)) .doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage())) .onErrorResume(e -> { log.warn("AI Foundation not available: {}", e.getMessage()); @@ -88,132 +61,68 @@ public class AiFoundationClient { } /** - * Get PluginManager via the pluginWrapper bean registered in our plugin context. - * Halo's DefaultPluginApplicationContextFactory registers pluginWrapper as a singleton: - * beanFactory.registerSingleton("pluginWrapper", pluginWrapper); - * Then PluginWrapper.getPluginManager() gives us the PluginManager instance. + * Call AI Foundation to classify text into one of the given choices using + * structured output ({@link OutputSpec#choice(List)}). + *

+ * This is the recommended way to do classification per the dev guide, + * as it is more reliable than prompt parsing. + * + * @param systemPrompt system prompt describing the task + * @param userPrompt the user input to classify + * @param choices the allowed classification values + * @param modelName the AiModel metadata.name, null or blank to use default model + * @return the selected choice string, or empty if AI Foundation is unavailable */ - private Object findPluginManager() { - try { - Object pluginWrapper = applicationContext.getBean("pluginWrapper"); - Method getPluginManagerMethod = pluginWrapper.getClass().getMethod("getPluginManager"); - getPluginManagerMethod.setAccessible(true); - Object pm = getPluginManagerMethod.invoke(pluginWrapper); - if (pm != null) { - log.info("Found PluginManager via pluginWrapper bean: {}", pm.getClass().getName()); - } - return pm; - } catch (NoSuchMethodException e) { - log.warn("pluginWrapper does not have getPluginManager() method: {}", e.getMessage()); - } catch (Exception e) { - log.warn("Failed to get PluginManager via pluginWrapper: {}", e.getMessage()); - } - log.warn("PluginManager not found"); - return null; + public Mono classify(String systemPrompt, String userPrompt, + List choices, String modelName) { + return aiModelService() + .flatMap(service -> service.languageModel(modelName != null ? modelName : "") + .flatMap(model -> model.generateText( + GenerateTextRequest.builder() + .system(systemPrompt) + .prompt(userPrompt) + .output(OutputSpec.choice(choices)) + .maxRetries(2) + .build())) + .map(result -> { + Object output = result.getOutput(); + return output != null ? String.valueOf(output).trim() : ""; + })) + .doOnError(e -> log.error("AI Foundation classify failed: {}", e.getMessage())) + .onErrorResume(e -> { + log.warn("AI Foundation not available: {}", e.getMessage()); + return Mono.empty(); + }); } /** - * Find the AiModelService bean from ai-foundation's PluginApplicationContext. - * Uses PluginManager.getPlugin() to get the plugin wrapper, then reflection - * to get the plugin's ApplicationContext. + * Check if AI Foundation is available: plugin installed and an + * AiModelService extension is enabled. */ - private Mono findAiModelService() { - return Mono.fromCallable(() -> { - Object pm = findPluginManager(); - if (pm == null) return null; - - // Call pm.getPlugin("ai-foundation") via reflection - Method getPluginMethod = pm.getClass().getMethod("getPlugin", String.class); - getPluginMethod.setAccessible(true); - Object pluginWrapper = getPluginMethod.invoke(pm, AI_FOUNDATION_PLUGIN_NAME); - if (pluginWrapper == null) { - log.debug("ai-foundation plugin not found in PluginManager"); - return null; - } - - // Call pluginWrapper.getPlugin() to get the plugin instance - Method getPluginInstanceMethod = pluginWrapper.getClass().getMethod("getPlugin"); - getPluginInstanceMethod.setAccessible(true); - Object pluginInstance = getPluginInstanceMethod.invoke(pluginWrapper); - if (pluginInstance == null) { - log.debug("ai-foundation plugin instance is null"); - return null; - } - - // Get the plugin's ApplicationContext via reflection on SpringPlugin - // DefaultSpringPlugin is package-private, so we need setAccessible - Method getCtxMethod = pluginInstance.getClass().getMethod("getApplicationContext"); - getCtxMethod.setAccessible(true); - ApplicationContext pluginAppContext = (ApplicationContext) getCtxMethod.invoke(pluginInstance); - - // Get the plugin classloader - Method getClassLoaderMethod = pluginWrapper.getClass().getMethod("getPluginClassLoader"); - getClassLoaderMethod.setAccessible(true); - ClassLoader pluginClassLoader = (ClassLoader) getClassLoaderMethod.invoke(pluginWrapper); - - // Load AiModelService from ai-foundation's classloader - Class aiModelServiceClass = pluginClassLoader.loadClass(AI_MODEL_SERVICE_CLASS); - - // Find the AiModelService bean in ai-foundation's ApplicationContext - Map beans = pluginAppContext.getBeansOfType(aiModelServiceClass); - if (beans.isEmpty()) { - log.debug("AiModelService bean not found in ai-foundation's ApplicationContext"); - return null; - } - - log.info("Found AiModelService bean in ai-foundation's ApplicationContext"); - Object result = beans.values().iterator().next(); - return (Object) result; - }).doOnError(e -> log.error("Failed to find AiModelService: {}", e.getMessage())); + public Mono isAvailable() { + return aiModelService().hasElement() + .onErrorResume(e -> { + log.debug("AI Foundation not available: {}", e.getMessage()); + return Mono.just(false); + }); } /** - * Call service.languageModel(modelName) or service.languageModel() via reflection. - * Returns Mono<LanguageModel> from ai-foundation's classloader. + * Obtain the enabled AiModelService extension via ExtensionGetter. + *

+ * Wrapped in {@link Mono#defer} with a {@link NoClassDefFoundError} guard so + * that the plugin still works when the optional ai-foundation dependency is + * not installed (the AiModelService API class is then absent from the + * classloader). */ - private Mono invokeLanguageModel(Object service, String modelName) { - return Mono.fromCallable(() -> { - Method method; - if (modelName != null && !modelName.isBlank()) { - method = service.getClass().getMethod("languageModel", String.class); - method.setAccessible(true); - return method.invoke(service, modelName); - } else { - method = service.getClass().getMethod("languageModel"); - method.setAccessible(true); - return method.invoke(service); + private Mono aiModelService() { + return Mono.defer(() -> { + try { + return extensionGetter.getEnabledExtension(AiModelService.class); + } catch (NoClassDefFoundError e) { + log.debug("AI Foundation API not on classpath: {}", e.getMessage()); + return Mono.empty(); } - }).flatMap(result -> { - if (result instanceof Mono mono) return mono; - return Mono.justOrEmpty(result); }); } - - /** - * Call model.generateText(prompt) via reflection, then extract text from result. - * Returns the generated text string. - */ - private Mono invokeGenerateText(Object model, String prompt) { - return Mono.fromCallable(() -> { - Method method = model.getClass().getMethod("generateText", String.class); - method.setAccessible(true); - return method.invoke(model, prompt); - }).flatMap(result -> { - if (result instanceof Mono mono) { - return mono.map(this::extractText); - } - return Mono.justOrEmpty(extractText(result)); - }); - } - - private String extractText(Object result) { - if (result == null) return null; - try { - Method getText = result.getClass().getMethod("getText"); - getText.setAccessible(true); - return (String) getText.invoke(result); - } catch (Exception e) { - throw new RuntimeException("Failed to call getText() on GenerateTextResult: " + e.getMessage(), e); - } - } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java index 02b897b..82f5ba5 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java @@ -42,6 +42,55 @@ public class ContextExtractor { }); } + /** + * Fetch previous replies in the comment thread to provide conversation history. + * Only includes replies created before the triggering reply. + */ + private Mono fetchConversationHistory(String commentName, String triggerReplyName) { + if (triggerReplyName == null || triggerReplyName.isBlank()) { + return Mono.just(""); + } + return client.fetch(Reply.class, triggerReplyName) + .flatMap(triggerReply -> { + var triggerTime = triggerReply.getMetadata().getCreationTimestamp(); + return client.list(Reply.class, + reply -> { + if (!commentName.equals(reply.getSpec().getCommentName())) { + return false; + } + if (triggerReplyName.equals(reply.getMetadata().getName())) { + return false; + } + // Only include replies created before the trigger reply + var replyTime = reply.getMetadata().getCreationTimestamp(); + return replyTime != null && triggerTime != null + && !replyTime.isAfter(triggerTime); + }, + null) + .collectList() + .map(replies -> { + if (replies.isEmpty()) return ""; + // Sort by creation time + replies.sort(java.util.Comparator.comparing( + r -> r.getMetadata().getCreationTimestamp())); + var sb = new StringBuilder(); + for (var r : replies) { + var owner = r.getSpec().getOwner(); + String name = (owner != null && owner.getDisplayName() != null) + ? owner.getDisplayName() : "匿名用户"; + boolean isAi = owner != null && owner.getAnnotations() != null + && "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai")); + String role = isAi ? "AI" : "用户"; + String content = extractReplyContent(r); + sb.append(role).append("(").append(name).append("): ") + .append(content).append("\n"); + } + return sb.toString(); + }); + }) + .defaultIfEmpty(""); + } + private Mono buildContext(Comment comment, boolean isAiConversation) { var commentContent = extractCommentContent(comment); var commentOwner = extractCommentOwner(comment); @@ -63,7 +112,8 @@ public class ContextExtractor { null, isAiConversation, formatPostDate(post), - commentCount + commentCount, + "" )) ) ) @@ -78,7 +128,8 @@ public class ContextExtractor { null, isAiConversation, "", - 0 + 0, + "" )); } @@ -93,7 +144,8 @@ public class ContextExtractor { null, isAiConversation, "", - 0 + 0, + "" )); } @@ -101,55 +153,68 @@ public class ContextExtractor { var replyContent = extractReplyContent(reply); var replyOwner = extractReplyOwner(reply); var subjectRef = comment.getSpec().getSubjectRef(); + var commentName = comment.getMetadata().getName(); + var replyName = reply.getMetadata().getName(); + + // Fetch conversation history for AI conversations + Mono historyMono = isAiConversation + ? fetchConversationHistory(commentName, replyName) + : Mono.just(""); if (subjectRef != null && "Post".equals(subjectRef.getKind())) { String postName = subjectRef.getName(); return client.fetch(Post.class, postName) .flatMap(post -> getPostContent(postName) - .flatMap(content -> getCommentCount(comment.getMetadata().getName()) - .map(commentCount -> new CommentContext( - comment.getMetadata().getName(), - postName, - post.getSpec().getSlug(), - replyContent, - replyOwner, - post.getSpec().getTitle(), - content, - reply.getMetadata().getName(), - isAiConversation, - formatPostDate(post), - commentCount - )) + .flatMap(content -> getCommentCount(commentName) + .flatMap(commentCount -> historyMono + .map(history -> new CommentContext( + commentName, + postName, + post.getSpec().getSlug(), + replyContent, + replyOwner, + post.getSpec().getTitle(), + content, + replyName, + isAiConversation, + formatPostDate(post), + commentCount, + history + )) + ) ) ) .defaultIfEmpty(new CommentContext( - comment.getMetadata().getName(), + commentName, postName, "", replyContent, replyOwner, "", "", - reply.getMetadata().getName(), + replyName, isAiConversation, "", - 0 + 0, + "" )); } - return Mono.just(new CommentContext( - comment.getMetadata().getName(), - "", - "", - replyContent, - replyOwner, - "", - "", - reply.getMetadata().getName(), - isAiConversation, - "", - 0 - )); + return historyMono + .map(history -> new CommentContext( + commentName, + "", + "", + replyContent, + replyOwner, + "", + "", + replyName, + isAiConversation, + "", + 0, + history + )); } private String extractCommentContent(Comment comment) { @@ -245,6 +310,7 @@ public class ContextExtractor { String replyTo, boolean isAiConversation, String postDate, - int commentCount + int commentCount, + String conversationHistory ) {} } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index 1755cc3..773682d 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -75,6 +75,7 @@ public class PromptBuilder { 文章(仅供理解上下文,不要复述): {{article}} + {{conversation_history}} 评论: {{comment}} """; @@ -103,6 +104,7 @@ public class PromptBuilder { .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") .replace("{{comment_count}}", String.valueOf(context.commentCount())) .replace("{{article}}", context.postTitle() + "\n" + context.postContent()) + .replace("{{conversation_history}}", formatConversationHistory(context)) .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); return prompt; @@ -133,6 +135,7 @@ public class PromptBuilder { .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") .replace("{{comment_count}}", String.valueOf(context.commentCount())) .replace("{{article}}", context.postTitle() + "\n" + context.postContent()) + .replace("{{conversation_history}}", formatConversationHistory(context)) .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); if (sentiment == null || "NEUTRAL".equals(sentiment)) { @@ -147,6 +150,18 @@ public class PromptBuilder { }); } + /** + * Format conversation history for inclusion in the prompt. + * Returns empty string if no history is available. + */ + private String formatConversationHistory(ContextExtractor.CommentContext context) { + String history = context.conversationHistory(); + if (history == null || history.isBlank()) { + return ""; + } + return "对话历史(供理解上下文):\n" + history + "\n"; + } + private Mono getPromptTemplate() { return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) .mapNotNull(cm -> { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java index 534eb69..22837ff 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java @@ -1,6 +1,7 @@ package top.nxxy335.commentaiautopilot.service; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; import org.springframework.stereotype.Component; import java.util.concurrent.ConcurrentHashMap; @@ -8,15 +9,18 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j @Component -public class RateLimitService { +public class RateLimitService implements DisposableBean { private final ConcurrentHashMap windowMap = new ConcurrentHashMap<>(); + private final Thread cleanupThread; + private volatile boolean running = true; public RateLimitService() { // 每5分钟清理过期窗口,防止内存泄漏 - Thread cleanupThread = new Thread(() -> { - while (!Thread.currentThread().isInterrupted()) { + cleanupThread = new Thread(() -> { + while (running && !Thread.currentThread().isInterrupted()) { try { Thread.sleep(5 * 60 * 1000); + if (!running) break; cleanup(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -55,4 +59,13 @@ public class RateLimitService { log.debug("[RateLimit] Cleaned up {} expired windows", removed); } } + + @Override + public void destroy() { + running = false; + if (cleanupThread != null) { + cleanupThread.interrupt(); + } + log.info("[RateLimit] Cleanup thread stopped"); + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java index 367dc72..9add930 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java @@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import java.util.List; + @Component @Slf4j public class ReviewService { @@ -14,58 +16,135 @@ public class ReviewService { this.aiFoundationClient = aiFoundationClient; } - private static final String REVIEW_PROMPT_TEMPLATE = """ - 请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复: + /** + * Safety classification choices. + * Using OutputSpec.choice for reliable structured output. + */ + private static final String SAFE = "安全"; + private static final String UNSAFE = "不安全"; + private static final List SAFETY_CHOICES = List.of(SAFE, UNSAFE); - 文章: - %s + /** + * Quality rating choices for content quality assessment. + * Used to produce a meaningful score (1-5) instead of binary 0/100. + */ + private static final String RATING_EXCELLENT = "5"; + private static final String RATING_GOOD = "4"; + private static final String RATING_AVERAGE = "3"; + private static final String RATING_POOR = "2"; + private static final String RATING_BAD = "1"; + private static final List QUALITY_CHOICES = List.of( + RATING_EXCELLENT, RATING_GOOD, RATING_AVERAGE, RATING_POOR, RATING_BAD + ); - 原始评论: - %s + private static final String SAFETY_SYSTEM_PROMPT = """ + 你是内容安全审核员。请严格评估AI生成的回复内容是否安全合规。 + 检查以下方面: + 1. 是否包含辱骂、仇恨、偏见或煽动性言论 + 2. 是否包含不适宜公开传播的内容 + 3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容 + 只返回"安全"或"不安全"。"""; - 生成的回复: - %s - - 请检查以下内容: - 1. 是否包含任何辱骂、仇恨、偏见或煽动性言论? - 2. 是否包含不适宜公开传播的内容? - 3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容? - - 请直接回答"安全"或"不安全"。如果"不安全",请重新生成一段符合规范的温和回复。 - """; + private static final String QUALITY_SYSTEM_PROMPT = """ + 你是AI回复质量评估员。请对AI生成的回复进行质量评分(1-5分)。 + 评分标准: + 5分:回复切题、自然流畅、语气恰当、有帮助 + 4分:回复较好,基本切题,语气自然 + 3分:回复一般,基本切题但略显生硬或冗长 + 2分:回复较差,偏离主题或语气不当 + 1分:回复很差,完全不相关或不当 + 只返回数字 1-5。"""; + /** + * Review AI reply content using two-stage assessment: + * 1. Safety check via OutputSpec.choice (安全/不安全) + * 2. Quality rating via OutputSpec.choice (1-5) — only if safe + * + *

Score mapping (0-100): + *

    + *
  • Unsafe → 0 (FAIL)
  • + *
  • Rating 5 → 100 (PASS)
  • + *
  • Rating 4 → 85 (PASS)
  • + *
  • Rating 3 → 70 (PASS)
  • + *
  • Rating 2 → 50 (PASS, borderline)
  • + *
  • Rating 1 → 30 (PASS, but low quality)
  • + *
+ */ public Mono review(String articleContent, String commentContent, String aiReply, String modelName) { - String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE, - truncate(articleContent, 2000), + String userPrompt = String.format(""" + 原始评论: + %s + + 生成的回复: + %s + + 请判断以上回复是否安全合规。""", truncate(commentContent, 500), truncate(aiReply, 500)); - return aiFoundationClient.chat(reviewPrompt, modelName) - .map(this::parseSafetyResult) + // Stage 1: Safety check + return aiFoundationClient.classify(SAFETY_SYSTEM_PROMPT, userPrompt, SAFETY_CHOICES, modelName) + .flatMap(safetyResult -> { + if (UNSAFE.equals(safetyResult)) { + log.warn("[Review] Content is UNSAFE"); + return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核不通过")); + } + if (!SAFE.equals(safetyResult)) { + log.warn("[Review] Unexpected safety result: {}, treating as unsafe", safetyResult); + return Mono.just(new ReviewResult(0, "FAIL", "内容安全审核结果异常")); + } + // Stage 2: Quality rating (only for safe content) + return rateQuality(commentContent, aiReply, modelName); + }) .defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过")) .onErrorResume(e -> { - log.warn("Review failed, auto-passing: {}", e.getMessage()); + log.warn("[Review] Review failed, auto-passing: {}", e.getMessage()); return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过")); }); } - private ReviewResult parseSafetyResult(String response) { - if (response == null || response.isBlank()) { - return new ReviewResult(100, "PASS", "审核无响应,自动通过"); - } - String trimmed = response.trim().toLowerCase(); - if (trimmed.contains("不安全") || trimmed.contains("unsafe")) { - log.warn("AI Review: content is UNSAFE, response: {}", response); - return new ReviewResult(0, "FAIL", "内容安全审核不通过"); - } - if (trimmed.contains("安全") || trimmed.contains("safe")) { - log.info("AI Review: content is SAFE"); - return new ReviewResult(100, "PASS", "内容安全审核通过"); - } - // If unclear response, default to pass - log.warn("AI Review: unclear response, auto-passing: {}", response); - return new ReviewResult(100, "PASS", "审核结果不明确,自动通过"); + /** + * Rate the quality of a safe AI reply (1-5) and map to a 0-100 score. + */ + private Mono rateQuality(String commentContent, String aiReply, String modelName) { + String qualityPrompt = String.format(""" + 评论: + %s + + 回复: + %s + + 请对以上回复进行质量评分(1-5分)。""", + truncate(commentContent, 500), + truncate(aiReply, 500)); + + return aiFoundationClient.classify(QUALITY_SYSTEM_PROMPT, qualityPrompt, QUALITY_CHOICES, modelName) + .map(rating -> { + int score = mapRatingToScore(rating); + String reason = "安全通过,质量评分: " + rating + "/5"; + log.info("[Review] Content is SAFE, quality rating: {}/5, score: {}", rating, score); + return new ReviewResult(score, "PASS", reason); + }) + .defaultIfEmpty(new ReviewResult(85, "PASS", "安全通过,质量评分默认 4/5")) + .onErrorResume(e -> { + log.warn("[Review] Quality rating failed, defaulting to 85: {}", e.getMessage()); + return Mono.just(new ReviewResult(85, "PASS", "安全通过,质量评分异常")); + }); + } + + /** + * Map a 1-5 quality rating to a 0-100 score. + */ + private int mapRatingToScore(String rating) { + return switch (rating) { + case RATING_EXCELLENT -> 100; + case RATING_GOOD -> 85; + case RATING_AVERAGE -> 70; + case RATING_POOR -> 50; + case RATING_BAD -> 30; + default -> 70; // default to average + }; } private String truncate(String text, int maxLength) { diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java index bf0b51b..1b144eb 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/SentimentService.java @@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import java.util.List; + @Component @Slf4j public class SentimentService { @@ -20,13 +22,27 @@ public class SentimentService { public static final String NEGATIVE = "NEGATIVE"; } - public Mono analyzeSentiment(String commentContent, String modelName) { - String prompt = buildSentimentPrompt(commentContent); + private static final List CHOICES = List.of( + SentimentResult.POSITIVE, SentimentResult.NEUTRAL, SentimentResult.NEGATIVE + ); - return aiFoundationClient.chat(prompt, modelName) - .map(response -> { - String sentiment = parseSentiment(response); - return new SentimentResult(sentiment, 1.0); + /** + * Analyze sentiment using AI Foundation structured output + * ({@code OutputSpec.choice}) for reliable classification. + */ + public Mono analyzeSentiment(String commentContent, String modelName) { + String systemPrompt = "你是一个情感分析助手。请分析评论的情感倾向,只返回 POSITIVE、NEUTRAL 或 NEGATIVE 之一。"; + String userPrompt = "分析以下评论的情感倾向:\n\n" + commentContent; + + return aiFoundationClient.classify(systemPrompt, userPrompt, CHOICES, modelName) + .map(sentiment -> { + String upper = sentiment.toUpperCase(); + // Validate against known choices; default to NEUTRAL if unexpected + if (!CHOICES.contains(upper)) { + log.warn("[Sentiment] Unexpected classification result: {}, defaulting to NEUTRAL", sentiment); + return new SentimentResult(SentimentResult.NEUTRAL, 0.0); + } + return new SentimentResult(upper, 1.0); }) .onErrorResume(e -> { log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage()); @@ -34,16 +50,4 @@ public class SentimentService { }) .defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0)); } - - private String buildSentimentPrompt(String commentContent) { - return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent; - } - - private String parseSentiment(String response) { - if (response == null || response.isBlank()) return SentimentResult.NEUTRAL; - String upper = response.trim().toUpperCase(); - if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE; - if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE; - return SentimentResult.NEUTRAL; - } } diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index d6709fd..767f704 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -54,7 +54,7 @@ spec: - $formkit: textarea name: customPromptTemplate label: 自定义Prompt模板 - value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}" + value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n{{conversation_history}}\n评论:\n{{comment}}" - $formkit: select name: enabledPresets label: 启用预设 diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 457a6ab..3eacd09 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -5,9 +5,17 @@ kind: Plugin metadata: # The name defines how the plugin is invoked, A unique name name: comment-ai-autopilot + annotations: + # Recommend installing AI Foundation from the app store after installing this plugin + # https://www.halo.run/store/apps/app-acslk9nu + "store.halo.run/recommended-apps": '["app-acslk9nu"]' spec: enabled: true requires: ">=2.25.0" + pluginDependencies: + # Optional dependency: plugin still loads without AI Foundation, + # but AI features require it to be installed and enabled. + ai-foundation?: "*" author: name: 暖心向阳335 website: https://nxxy335.top @@ -22,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.1" + version: "1.0.0-beta.2-kx7m2p" diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue index 5be0f66..2153a7b 100644 --- a/ui/src/views/HomeView.vue +++ b/ui/src/views/HomeView.vue @@ -108,152 +108,11 @@ - -
- - -

情感分布

-
-
-
-
-
- 正面 - {{ stats?.sentimentDistribution?.POSITIVE || 0 }} -
-
-
-
-
-
-
-
-
-
- 中性 - {{ stats?.sentimentDistribution?.NEUTRAL || 0 }} -
-
-
-
-
-
-
-
-
-
- 负面 - {{ stats?.sentimentDistribution?.NEGATIVE || 0 }} -
-
-
-
-
-
-
-
-
-
- 未知 - {{ stats?.sentimentDistribution?.UNKNOWN || 0 }} -
-
-
-
-
-
-
-
- - - -
-

近7日回复趋势

-
- - - -
-
-
-
-
{{ day.count }}
-
-
{{ formatTrendDate(day.date) }}
-
-
-
- 暂无数据 -
-
-
- - -
- - -

平均审核评分

-
-
{{ stats?.avgScore?.toFixed(1) || '0.0' }}
-
-
-
-
-
- 0 - 5 - 10 -
-
-
-
- - + +

快捷操作

-
+