diff --git a/README.md b/README.md index 35becc3..3cc576a 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ # AI回评 / Comment AI Autopilot -基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复。 +基于 AI 的 Halo 博客评论自动回复插件,支持多 AI 角色、自审核、自动发布和对话式连续回复。 ## 功能特性 -- **自动回复** — 监听新评论,自动调用AI生成回复,支持多轮对话上下文 +- **多 AI 角色** — 支持创建多个 AI 角色,每个角色有独立的昵称、人格提示词和 Gravatar 头像,可为不同文章指定不同角色 +- **自动回复** — 监听新评论,自动调用 AI 生成回复,支持多轮对话上下文 - **多语言适配** — 根据评论语言自动用对应语言回复 - **情感分析** — 分析评论情感倾向(正面/中性/负面),根据情感调整回复语气 -- **草稿模式** — AI回复先存为草稿,管理员审核后再发布,支持批量操作 -- **失败重试** — AI生成失败时自动重试,指数退避策略 -- **文章/页面级开关** — 在文章编辑器中直接控制是否启用AI回复,文章默认开启,页面默认关闭 -- **评论者黑名单** — 支持按名称和邮箱屏蔽指定评论者,可从评论列表选择 -- **手动触发** — 在评论管理页面对历史评论手动触发AI回复 -- **AI角色** — 自定义AI回复者的昵称、人格提示词和Gravatar头像,设置页面实时预览头像 -- **安全审核** — AI生成的内容经过安全审核,不合规内容自动拒绝 -- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表 +- **草稿模式** — AI 回复先存为草稿,管理员审核后再发布,支持批量操作 +- **失败重试** — AI 生成失败时自动重试,指数退避策略 +- **对话轮次限制** — 同一评论线程中限制 AI 最多回复轮次,防止无限对话 +- **速率限制** — 每分钟最大 AI 回复数量,防止批量评论消耗过多额度 +- **文章/页面级开关** — 在文章编辑器中直接控制是否启用 AI 回复,文章默认开启,页面默认关闭 +- **评论者黑名单** — 支持按名称、邮箱和正则表达式屏蔽指定评论者,可从评论列表选择 +- **手动触发** — 在评论管理页面对历史评论手动触发 AI 回复 +- **安全审核** — AI 生成的内容经过安全审核,不合规内容自动拒绝 +- **Prompt 模板** — 支持自定义 Prompt 模板,提供多种模板变量(文章标题、发布日期、评论数等) +- **Prompt 预设** — 内置友好型、专业型、幽默型、简洁型预设风格,可多选组合 +- **仪表盘统计** — 显示回复数、情感分布、每日回复趋势等图表,支持时间范围切换 +- **插件健康检查** — 实时检测 AI Foundation 连接状态和模型可用性 - **日志筛选** — 按状态、情感筛选,关键词搜索 - **数据清理** — 自动清理超过指定天数的旧记录 -- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的AI模型能力 +- **AI Foundation 集成** — 必须安装 Halo AI Foundation 插件,使用其提供的 AI 模型能力 ## 前置要求 @@ -26,7 +31,7 @@ ## 安装 -1. 前往 [Releases](https://github.com/暖心向阳335/comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 +1. 前往 [Releases](https://github.com/sunny-335/plugin-comment-ai-autopilot/releases) 下载最新的 `.jar` 文件 2. 登录 Halo 管理后台 3. 进入 **插件** → **已安装** → 点击右上角 **安装** 按钮 4. 选择下载的 `.jar` 文件上传 @@ -36,7 +41,7 @@ ```bash # 克隆仓库 -git clone https://github.com/暖心向阳335/comment-ai-autopilot.git +git clone https://github.com/sunny-335/plugin-comment-ai-autopilot.git cd plugin-comment-ai-autopilot # 构建 @@ -59,7 +64,7 @@ pnpm dev ## 文档 -完整文档请访问 [AI回评文档站](https://暖心向阳335.github.io/comment-ai-autopilot/) +完整文档请访问 [AI回评文档站](https://nxxy335.top/comment-ai-autopilot) ## 许可证 diff --git a/build.gradle b/build.gradle index 6010441..9bd62d3 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group 'top.nxxy335.commentaiautopilot' -version '0.0.1-w5s2t7' +version '0.0.1-t5w8r3' repositories { mavenCentral() diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 38718ab..a49932f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -4,6 +4,7 @@ export default defineConfig({ title: "AI回评", description: "Halo AI回评插件文档", lang: "zh-CN", + base: "/comment-ai-autopilot/", themeConfig: { nav: [ { text: "指南", link: "/guide/introduction" }, diff --git a/docs/guide/prompt.md b/docs/guide/prompt.md index 4849b4b..5516821 100644 --- a/docs/guide/prompt.md +++ b/docs/guide/prompt.md @@ -15,6 +15,9 @@ Prompt模板控制AI生成回复时的完整提示词结构。 - 自然对话,不要写小作文 - 只有评论涉及具体内容时才针对性回应 +文章标题:{{post_title}} +发布日期:{{post_date}} +评论数:{{comment_count}} 文章(仅供理解上下文,不要复述): {{article}} @@ -29,8 +32,11 @@ Prompt模板控制AI生成回复时的完整提示词结构。 | `{{persona_prompt}}` | AI角色人格提示词 | 始终注入 | | `{{safety_prompt}}` | 安全规范提示词 | 始终注入 | | `{{sentiment_prompt}}` | 情感语气提示词 | 情感分析后自动注入,不在模板中显式使用 | -| `{{article}}` | 文章/页面内容 | 始终注入 | -| `{{comment}}` | 评论内容 | 始终注入 | +| `{{post_title}}` | 文章标题 | 始终注入 | +| `{{post_date}}` | 文章发布日期(如 2024-01-15) | 始终注入 | +| `{{comment_count}}` | 该文章的评论数 | 始终注入 | +| `{{article}}` | 文章/页面内容(含标题) | 始终注入 | +| `{{comment}}` | 评论内容(含评论者名称) | 始终注入 | | `{{conversation}}` | 对话上下文 | 多轮对话时注入 | ## 情感提示 @@ -56,6 +62,21 @@ Prompt模板控制AI生成回复时的完整提示词结构。 1. 保留 `{{persona_prompt}}` 和 `{{safety_prompt}}` 变量 2. 保留 `{{article}}` 和 `{{comment}}` 变量 -3. 在变量之间添加清晰的分隔和指令 -4. 避免让AI复述文章内容 -5. 控制回复长度和风格 +3. 利用 `{{post_title}}`、`{{post_date}}`、`{{comment_count}}` 提供更丰富的上下文 +4. 在变量之间添加清晰的分隔和指令 +5. 避免让AI复述文章内容 +6. 控制回复长度和风格 + +## 变量使用示例 + +### 根据评论数调整回复风格 + +``` +{{comment_count}}条评论说明这篇文章{{#if comment_count > 10}}很受欢迎{{/if}}。 +``` + +### 利用发布日期 + +``` +这篇文章发布于{{post_date}},回复时请考虑时效性。 +``` diff --git a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java index 57e9c12..d8de177 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPlugin.java @@ -1,12 +1,17 @@ package top.nxxy335.commentaiautopilot; import org.springframework.stereotype.Component; +import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.IndexSpecs; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; +import run.halo.app.extension.Metadata; import run.halo.app.plugin.BasePlugin; import run.halo.app.plugin.PluginContext; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; +import top.nxxy335.commentaiautopilot.extension.AiPersona; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; /** *

Plugin main class to manage the lifecycle of the plugin.

@@ -16,14 +21,17 @@ import top.nxxy335.commentaiautopilot.extension.AiCommentReply; * @author 暖心向阳335 * @since 1.0.0 */ +@Slf4j @Component public class CommentAiAutopilotPlugin extends BasePlugin { private final SchemeManager schemeManager; + private final ReactiveExtensionClient client; - public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager) { + public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager, ReactiveExtensionClient client) { super(pluginContext); this.schemeManager = schemeManager; + this.client = client; } @Override @@ -36,10 +44,36 @@ public class CommentAiAutopilotPlugin extends BasePlugin { indexSpecs.add(IndexSpecs.single("spec.status", String.class) .indexFunc(ext -> ext.getSpec().getStatus())); }); + schemeManager.register(AiPersona.class); + + // 初始化默认AI角色"小回" + initDefaultPersona(); + } + + private void initDefaultPersona() { + client.fetch(AiPersona.class, "default-ai-persona") + .switchIfEmpty(Mono.defer(() -> { + log.info("初始化默认AI角色:小回"); + AiPersona persona = new AiPersona(); + persona.setMetadata(new Metadata()); + persona.getMetadata().setName("default-ai-persona"); + AiPersona.AiPersonaSpec spec = new AiPersona.AiPersonaSpec(); + spec.setDisplayName("小回"); + spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。"); + spec.setEmail(""); + spec.setIsDefault(true); + persona.setSpec(spec); + return client.create(persona); + })) + .subscribe( + created -> log.info("默认AI角色已就绪"), + err -> log.warn("初始化默认AI角色失败: {}", err.getMessage()) + ); } @Override public void stop() { schemeManager.unregister(Scheme.buildFromType(AiCommentReply.class)); + schemeManager.unregister(Scheme.buildFromType(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 8b8245e..2d4f13b 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/endpoint/CommentAiAutopilotEndpoint.java @@ -1,6 +1,7 @@ package top.nxxy335.commentaiautopilot.endpoint; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; @@ -8,6 +9,7 @@ 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 +18,8 @@ import run.halo.app.extension.ListOptions; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.PageRequestImpl; import top.nxxy335.commentaiautopilot.extension.AiCommentReply; +import top.nxxy335.commentaiautopilot.extension.AiPersona; +import top.nxxy335.commentaiautopilot.service.AiFoundationClient; import top.nxxy335.commentaiautopilot.service.AiReplyCleanupService; import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator; @@ -44,14 +48,16 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { private final ReactiveExtensionClient client; private final AiReplyOrchestrator orchestrator; private final AiReplyCleanupService cleanupService; + private final ObjectProvider aiFoundationClientProvider; private final ObjectMapper objectMapper; private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; - public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService) { + public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator, AiReplyCleanupService cleanupService, ObjectProvider aiFoundationClientProvider) { this.client = client; this.orchestrator = orchestrator; this.cleanupService = cleanupService; + this.aiFoundationClientProvider = aiFoundationClientProvider; this.objectMapper = new ObjectMapper(); } @@ -72,6 +78,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply) .GET("/commenters", this::listCommenters) .POST("/cleanup", this::triggerCleanup) + .GET("/health", this::health) + .GET("/personas", this::listPersonas) + .GET("/personas/{name}", this::getPersonaByName) + .POST("/personas", this::createPersona) + .PUT("/personas/{name}", this::updatePersona) + .DELETE("/personas/{name}", this::deletePersona) .build(); } @@ -142,9 +154,39 @@ 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(replies -> { + .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; + } + long total = replies.size(); long passCount = replies.stream() .filter(r -> "PASS".equals(r.getSpec().getStatus())).count(); @@ -174,11 +216,10 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } } - ZoneId zoneId = ZoneId.systemDefault(); + // 计算 dailyTrend DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - LocalDate today = LocalDate.now(zoneId); Map dailyMap = new HashMap<>(); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < trendDays; i++) { dailyMap.put(today.minusDays(i), 0L); } for (var r : replies) { @@ -194,7 +235,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } } List dailyTrend = new ArrayList<>(); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < trendDays; i++) { LocalDate date = today.minusDays(i); dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date))); } @@ -212,29 +253,35 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { } private Mono getPersona(ServerRequest request) { - return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return new PersonaResponse("小回", "", ""); - String personaJson = data.get("persona"); - if (personaJson == null || personaJson.isBlank()) return new PersonaResponse("小回", "", ""); - try { - JsonNode node = objectMapper.readTree(personaJson); - String name = node.has("personaName") ? node.get("personaName").asText("小回") : "小回"; - String prompt = node.has("personaPrompt") ? node.get("personaPrompt").asText("") : ""; - String email = node.has("personaEmail") ? node.get("personaEmail").asText("") : ""; - return new PersonaResponse(name, prompt, email); - } catch (Exception e) { - log.warn("Failed to parse persona config: {}", e.getMessage()); - return new PersonaResponse("小回", "", ""); + return client.list(AiPersona.class, + persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()), + null) + .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) {} } + return ServerResponse.ok().bodyValue(Map.of( + "name", persona.getSpec().getDisplayName(), + "prompt", persona.getSpec().getPrompt() != null ? persona.getSpec().getPrompt() : "", + "avatar", avatarUrl + )); }) - .defaultIfEmpty(new PersonaResponse("小回", "", "")) - .onErrorResume(e -> { - log.warn("Failed to fetch persona settings: {}", e.getMessage()); - return Mono.just(new PersonaResponse("小回", "", "")); - }) - .flatMap(persona -> ServerResponse.ok().bodyValue(persona)); + .switchIfEmpty(ServerResponse.ok().bodyValue(Map.of( + "name", "小回", + "prompt", "", + "avatar", "" + ))); } public record DailyCount(String date, long count) {} @@ -489,9 +536,12 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return ServerResponse.badRequest() .bodyValue(Map.of("message", "该评论已有AI回复记录")); } - // Trigger the orchestrator - return orchestrator.processComment(commentName, null, false) - .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))); + // Read persona name from post annotations + return getPersonaNameFromComment(commentName) + .flatMap(personaName -> + orchestrator.processComment(commentName, null, false, personaName) + .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复"))) + ); }); } @@ -514,15 +564,44 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { return ServerResponse.badRequest() .bodyValue(Map.of("message", "该回复已有AI对话记录")); } - return orchestrator.processComment(commentName, replyName, true) - .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))); + return getPersonaNameFromComment(commentName) + .flatMap(personaName -> + orchestrator.processComment(commentName, replyName, true, personaName) + .then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复"))) + ); }); }) .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) { // Find the Reply that belongs to the same comment and was created by AI + // If the record has a quoteReply, match by that too for precision return client.list(Reply.class, reply -> { if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) { @@ -531,7 +610,14 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { var owner = reply.getSpec().getOwner(); if (owner == null) return false; var annotations = owner.getAnnotations(); - return annotations != null && "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai")); + if (annotations == null || !"true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"))) { + return false; + } + // If record has a quoteReply, also match by quoteReply for precision + if (record.getSpec().getReplyTo() != null && !record.getSpec().getReplyTo().isBlank()) { + return record.getSpec().getReplyTo().equals(reply.getSpec().getQuoteReply()); + } + return true; }, null) .next() @@ -586,4 +672,97 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint { .bodyValue(Map.of("message", "清理失败: " + e.getMessage())); }); } + + private Mono health(ServerRequest request) { + AiFoundationClient aiClient = aiFoundationClientProvider.getIfAvailable(); + boolean aiFoundationInstalled = aiClient != null; + + if (!aiFoundationInstalled) { + return ServerResponse.ok().bodyValue( + new HealthResponse(false, false, false, "", "unhealthy")); + } + + // AI Foundation is installed, check if it's enabled and model is available + return aiClient.chat("ping", null) + .map(response -> (HealthResponse) new HealthResponse(true, true, true, "default", "healthy")) + .onErrorResume(e -> { + log.debug("Health check: AI Foundation call failed: {}", e.getMessage()); + return Mono.just(new HealthResponse(true, true, false, "", "degraded")); + }) + .flatMap(health -> ServerResponse.ok().bodyValue(health)); + } + + public record HealthResponse( + boolean aiFoundationInstalled, + boolean aiFoundationEnabled, + boolean modelAvailable, + String modelName, + String status + ) {} + + private Mono listPersonas(ServerRequest request) { + return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted()) + .collectList() + .flatMap(personas -> ServerResponse.ok().bodyValue(personas)); + } + + private Mono getPersonaByName(ServerRequest request) { + var name = request.pathVariable("name"); + return client.fetch(AiPersona.class, name) + .flatMap(persona -> ServerResponse.ok().bodyValue(persona)) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + private Mono createPersona(ServerRequest request) { + return request.bodyToMono(AiPersona.class) + .flatMap(persona -> { + if (persona.getMetadata() == null) { + persona.setMetadata(new run.halo.app.extension.Metadata()); + } + if (persona.getMetadata().getName() == null || persona.getMetadata().getName().isBlank()) { + persona.getMetadata().setName("ai-persona-" + java.util.UUID.randomUUID().toString().substring(0, 8)); + } + return client.create(persona) + .flatMap(created -> ServerResponse.ok().bodyValue(created)) + .onErrorResume(e -> { + log.warn("Failed to create persona: {}", e.getMessage()); + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "创建角色失败: " + e.getMessage())); + }); + }) + .switchIfEmpty(ServerResponse.badRequest() + .bodyValue(Map.of("message", "请求体不能为空"))); + } + + private Mono updatePersona(ServerRequest request) { + var name = request.pathVariable("name"); + return request.bodyToMono(AiPersona.class) + .flatMap(updatedPersona -> client.fetch(AiPersona.class, name) + .flatMap(existing -> { + existing.setSpec(updatedPersona.getSpec()); + return client.update(existing); + }) + .flatMap(saved -> ServerResponse.ok().bodyValue(saved)) + .onErrorResume(e -> { + log.warn("Failed to update persona {}: {}", name, e.getMessage()); + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "更新角色失败: " + e.getMessage())); + }) + ) + .switchIfEmpty(ServerResponse.notFound().build()); + } + + private Mono deletePersona(ServerRequest request) { + var name = request.pathVariable("name"); + return client.fetch(AiPersona.class, name) + .flatMap(persona -> { + if (persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault())) { + return ServerResponse.badRequest() + .bodyValue(Map.of("message", "默认角色不可删除,请先将其他角色设为默认")); + } + return client.delete(persona) + .then(ServerResponse.ok().bodyValue(Map.of("message", "deleted"))); + }) + .switchIfEmpty(ServerResponse.notFound().build()); + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java index c04c1e6..2012362 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiCommentReply.java @@ -56,5 +56,8 @@ public class AiCommentReply extends AbstractExtension { @Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE") private String sentiment; + + @Schema(description = "使用的AI角色名称") + private String personaName; } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java new file mode 100644 index 0000000..7d2ee31 --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/extension/AiPersona.java @@ -0,0 +1,41 @@ +package top.nxxy335.commentaiautopilot.extension; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +@Data +@EqualsAndHashCode(callSuper = true) +@GVK( + group = "comment-ai-autopilot.nxxy335.top", + version = "v1alpha1", + kind = "AiPersona", + plural = "aipersonas", + singular = "aipersona" +) +public class AiPersona extends AbstractExtension { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private AiPersonaSpec spec; + + @Data + @Schema(name = "AiPersonaSpec") + public static class AiPersonaSpec { + + @Schema(description = "角色昵称") + private String displayName; + + @Schema(description = "人格提示词") + private String prompt; + + @Schema(description = "邮箱(用于Gravatar头像)") + private String email; + + @Schema(description = "是否为默认角色") + @JsonProperty("isDefault") + private Boolean isDefault; + } +} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java b/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java new file mode 100644 index 0000000..3cde27a --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/AiPersonaReconciler.java @@ -0,0 +1,31 @@ +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 2543f23..f8ef3a0 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/CommentReconciler.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.content.Comment; +import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; @@ -29,6 +30,7 @@ public class CommentReconciler implements Reconciler { 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"; // Record the time when this bean was created (plugin startup time) private final Instant pluginStartTime = Instant.now(); @@ -88,10 +90,13 @@ public class CommentReconciler implements Reconciler { 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: {}", name); + log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName); asyncStarted.set(true); - orchestrator.processComment(name, null, false) + orchestrator.processComment(name, null, false, personaName) .subscribeOn(Schedulers.boundedElastic()) .doFinally(signal -> { processingLocks.remove(name); @@ -132,6 +137,29 @@ 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 -> { + var annotations = post.getMetadata().getAnnotations(); + if (annotations != null) { + String persona = annotations.get(AI_PERSONA_ANNOTATION); + if (persona != null && !persona.isBlank()) { + return persona; + } + } + 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 7d04b3d..2f49d3a 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/listener/ReplyReconciler.java @@ -4,6 +4,8 @@ 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.Comment; +import run.halo.app.core.extension.content.Post; import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Controller; @@ -27,6 +29,7 @@ public class ReplyReconciler implements Reconciler { 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(); @@ -109,8 +112,9 @@ public class ReplyReconciler implements Reconciler { client.update(reply); // Reply to AI → trigger AI reply (conversation continuation) - log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation", name); - orchestrator.processComment(parentCommentName, name, true) + String personaName = getPersonaNameFromComment(parentCommentName); + log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName); + orchestrator.processComment(parentCommentName, name, true, personaName) .subscribeOn(Schedulers.boundedElastic()) .subscribe( null, @@ -142,6 +146,33 @@ 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 -> { + var annotations = post.getMetadata().getAnnotations(); + if (annotations != null) { + String persona = annotations.get(AI_PERSONA_ANNOTATION); + if (persona != null && !persona.isBlank()) { + return persona; + } + } + 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 af233d1..ae1456c 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyCleanupService.java @@ -49,7 +49,7 @@ public class AiReplyCleanupService implements DisposableBean { if (cleanupJson == null || cleanupJson.isBlank()) return true; try { JsonNode node = objectMapper.readTree(cleanupJson); - return node.has("cleanupEnabled") && node.get("cleanupEnabled").asBoolean(true); + return !node.has("cleanupEnabled") || node.get("cleanupEnabled").asBoolean(true); } catch (Exception e) { log.warn("[Cleanup] Failed to parse cleanup config: {}", e.getMessage()); return true; diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java index e0288ab..b8a1b9c 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/AiReplyOrchestrator.java @@ -7,6 +7,7 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; @@ -29,6 +30,7 @@ public class AiReplyOrchestrator { private final ReviewService reviewService; private final CommentReplyPublisher commentReplyPublisher; private final FilterService filterService; + private final RateLimitService rateLimitService; private final ReactiveExtensionClient client; private final ObjectMapper objectMapper; @@ -43,6 +45,7 @@ public class AiReplyOrchestrator { ReviewService reviewService, CommentReplyPublisher commentReplyPublisher, FilterService filterService, + RateLimitService rateLimitService, ReactiveExtensionClient client) { this.contextExtractor = contextExtractor; this.promptBuilder = promptBuilder; @@ -51,6 +54,7 @@ public class AiReplyOrchestrator { this.reviewService = reviewService; this.commentReplyPublisher = commentReplyPublisher; this.filterService = filterService; + this.rateLimitService = rateLimitService; this.client = client; this.objectMapper = new ObjectMapper(); } @@ -61,8 +65,10 @@ public class AiReplyOrchestrator { * @param commentName the parent Comment name * @param replyName the Reply name that triggered this (null for top-level comments) * @param isAiConversation true when someone replied to AI's reply (conversation continuation) + * @param personaName the persona name to use (null for default persona) */ - public Mono processComment(String commentName, String replyName, boolean isAiConversation) { + public Mono processComment(String commentName, String replyName, boolean isAiConversation, + String personaName) { String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top"; // In-memory dedup: if already processing, skip immediately @@ -71,8 +77,8 @@ public class AiReplyOrchestrator { return Mono.empty(); } - log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}", - commentName, replyName, isAiConversation); + log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}", + commentName, replyName, isAiConversation, personaName); return isAutoReplyEnabled() .flatMap(enabled -> { @@ -80,12 +86,18 @@ public class AiReplyOrchestrator { log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName); return Mono.empty(); } - return filterService.shouldProcess(commentName) - .flatMap(shouldProcess -> { - if (!shouldProcess) { - log.info("[Orchestrator] Filtered out by rules: {}", commentName); + return getRateLimit() + .flatMap(rateLimit -> { + if (!rateLimitService.tryAcquire(rateLimit)) { + log.info("[Orchestrator] 速率限制,跳过: {}", commentName); return Mono.empty(); } + return filterService.shouldProcess(commentName) + .flatMap(shouldProcess -> { + if (!shouldProcess) { + log.info("[Orchestrator] Filtered out by rules: {}", commentName); + return Mono.empty(); + } // For top-level comments: skip if we already have ANY reply record // For AI conversation: skip if we already replied to THIS specific reply if (!isAiConversation) { @@ -95,16 +107,27 @@ public class AiReplyOrchestrator { log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName); return Mono.empty(); } - return doProcess(commentName, replyName, isAiConversation); + return doProcess(commentName, replyName, isAiConversation, personaName); }); } - return hasExistingConversationReply(replyName) - .flatMap(hasReply -> { - if (hasReply) { - log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName); - return Mono.empty(); - } - return doProcess(commentName, replyName, isAiConversation); + // Check conversation rounds limit + return getMaxConversationRounds() + .flatMap(maxRounds -> getConversationRounds(commentName) + .flatMap(rounds -> { + if (rounds >= maxRounds) { + log.info("[Orchestrator] 对话轮次已达上限({}/{}), 跳过: {}", rounds, maxRounds, commentName); + return Mono.empty(); + } + return hasExistingConversationReply(replyName) + .flatMap(hasReply -> { + if (hasReply) { + log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName); + return Mono.empty(); + } + return doProcess(commentName, replyName, isAiConversation, personaName); + }); + }) + ); }); }); }) @@ -117,16 +140,17 @@ public class AiReplyOrchestrator { .then(); } - private Mono doProcess(String commentName, String replyName, boolean isAiConversation) { + private Mono doProcess(String commentName, String replyName, boolean isAiConversation, + String personaName) { return getModelName().flatMap(modelName -> contextExtractor.extract(commentName, replyName, isAiConversation) .flatMap(context -> sentimentService.analyzeSentiment(context.commentContent(), modelName) .flatMap(sentimentResult -> { log.info("[Orchestrator] Sentiment for {}: {} (confidence: {})", commentName, sentimentResult.sentiment(), sentimentResult.confidence()); - return promptBuilder.buildPrompt(context, sentimentResult.sentiment()) - .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment()) - .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName)) + return promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName) + .flatMap(prompt -> createAiCommentReply(context, sentimentResult.sentiment(), personaName) + .flatMap(replyRecord -> generateAndPublish(prompt, context, replyRecord, modelName, personaName)) ); }) ) @@ -175,13 +199,14 @@ public class AiReplyOrchestrator { * Includes retry logic for empty AI replies and review failures. */ private Mono generateAndPublish(String prompt, ContextExtractor.CommentContext context, - AiCommentReply replyRecord, String modelName) { + AiCommentReply replyRecord, String modelName, + String personaName) { return aiReplyService.generateReply(prompt, modelName) .defaultIfEmpty("") .flatMap(aiReply -> { if (aiReply.isBlank()) { log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId()); - return retryOrFail(replyRecord, context, modelName, "AI generated empty reply"); + return retryOrFail(replyRecord, context, modelName, personaName, "AI generated empty reply"); } log.info("[Orchestrator] AI generated reply for {}: {} chars", @@ -196,16 +221,16 @@ public class AiReplyOrchestrator { context.commentId()); // Save the failed reply content, then retry return updateRecord(replyRecord, aiReply, 0, "FAIL", false) - .then(retryOrFail(replyRecord, context, modelName, "Content safety review failed")); + .then(retryOrFail(replyRecord, context, modelName, personaName, "Content safety review failed")); } - return publishReply(context, aiReply, replyRecord, reviewResult.score()); + return publishReply(context, aiReply, replyRecord, reviewResult.score(), personaName); }) .switchIfEmpty( - publishReply(context, aiReply, replyRecord, 100) + publishReply(context, aiReply, replyRecord, 100, personaName) ) .onErrorResume(e -> { log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage()); - return publishReply(context, aiReply, replyRecord, 100); + return publishReply(context, aiReply, replyRecord, 100, personaName); }); }); } @@ -219,6 +244,7 @@ public class AiReplyOrchestrator { private Mono retryOrFail(AiCommentReply replyRecord, ContextExtractor.CommentContext context, String modelName, + String personaName, String reason) { return getMaxRetryCount().flatMap(maxRetry -> { int currentRetryCount = replyRecord.getSpec().getRetryCount() != null @@ -233,7 +259,7 @@ public class AiReplyOrchestrator { // Update retryCount and reset status to PENDING return updateRecordForRetry(replyRecord, newRetryCount) .delayElement(Duration.ofSeconds(delaySeconds)) - .then(retryGenerate(context, replyRecord, modelName)); + .then(retryGenerate(context, replyRecord, modelName, personaName)); } else { log.warn("[Orchestrator] Max retry count ({}) exceeded for: {}, marking as FAIL. Reason: {}", maxRetry, context.commentId(), reason); @@ -248,10 +274,11 @@ public class AiReplyOrchestrator { */ private Mono retryGenerate(ContextExtractor.CommentContext context, AiCommentReply replyRecord, - String modelName) { + String modelName, + String personaName) { return sentimentService.analyzeSentiment(context.commentContent(), modelName) - .flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment()) - .flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName)) + .flatMap(sentimentResult -> promptBuilder.buildPrompt(context, sentimentResult.sentiment(), personaName) + .flatMap(prompt -> generateAndPublish(prompt, context, replyRecord, modelName, personaName)) ); } @@ -281,11 +308,11 @@ public class AiReplyOrchestrator { * Publish the reply and update the record to PASS + published=true. */ private Mono publishReply(ContextExtractor.CommentContext context, String aiReply, - AiCommentReply replyRecord, int score) { + AiCommentReply replyRecord, int score, String personaName) { return isAutoPublishEnabled() .flatMap(autoPublish -> { return commentReplyPublisher.publishReply( - context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish) + context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish, personaName) .flatMap(publishedReply -> { log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId()); return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish); @@ -394,7 +421,78 @@ public class AiReplyOrchestrator { .defaultIfEmpty(3); } - private Mono createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) { + private Mono getMaxConversationRounds() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (node.has("maxConversationRounds")) { + return node.get("maxConversationRounds").asInt(8); + } + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse maxConversationRounds from ConfigMap: {}", e.getMessage()); + } + return null; + }) + .onErrorResume(e -> { + log.debug("[Orchestrator] Failed to fetch maxConversationRounds setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); + }) + .defaultIfEmpty(8); + } + + private Mono getConversationRounds(String commentName) { + return client.list(Reply.class, + reply -> { + if (!commentName.equals(reply.getSpec().getCommentName())) { + return false; + } + var owner = reply.getSpec().getOwner(); + if (owner == null) return false; + // Check AI annotation marker (CommentReplyPublisher sets this on all AI replies) + var annotations = owner.getAnnotations(); + return annotations != null + && "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai")); + }, + null) + .collectList() + .map(replies -> replies.size()) + .onErrorResume(e -> { + log.warn("[Orchestrator] Failed to count conversation rounds: {}", e.getMessage()); + return Mono.just(0); + }); + } + + private Mono getRateLimit() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return null; + String basicJson = data.get("basic"); + if (basicJson == null || basicJson.isBlank()) return null; + try { + JsonNode node = objectMapper.readTree(basicJson); + if (node.has("rateLimitPerMinute")) { + return node.get("rateLimitPerMinute").asInt(10); + } + } catch (Exception e) { + log.warn("[Orchestrator] Failed to parse rateLimitPerMinute from ConfigMap: {}", e.getMessage()); + } + return null; + }) + .onErrorResume(e -> { + log.debug("[Orchestrator] Failed to fetch rateLimitPerMinute setting from ConfigMap: {}", e.getMessage()); + return Mono.empty(); + }) + .defaultIfEmpty(10); + } + + private Mono createAiCommentReply(ContextExtractor.CommentContext context, String sentiment, + String personaName) { AiCommentReply record = new AiCommentReply(); record.setMetadata(new Metadata()); record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8)); @@ -410,6 +508,7 @@ public class AiReplyOrchestrator { record.getSpec().setIsAiConversation(context.isAiConversation()); record.getSpec().setPublished(false); record.getSpec().setSentiment(sentiment); + record.getSpec().setPersonaName(personaName); return client.create(record) .doOnSuccess(created -> log.info("[Orchestrator] Created AiCommentReply record: {}", created.getMetadata().getName())); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java index 2a7bf83..b54ede4 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/CommentReplyPublisher.java @@ -1,15 +1,13 @@ package top.nxxy335.commentaiautopilot.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; -import run.halo.app.extension.ConfigMap; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; +import top.nxxy335.commentaiautopilot.extension.AiPersona; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -23,24 +21,23 @@ import java.util.UUID; public class CommentReplyPublisher { private final ReactiveExtensionClient client; - private final ObjectMapper objectMapper; public CommentReplyPublisher(ReactiveExtensionClient client) { this.client = client; - this.objectMapper = new ObjectMapper(); } - private static final String DEFAULT_PERSONA_NAME = "小回"; private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-"; - private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap"; /** * Publish a reply to a comment automatically using AI Persona identity. * Includes a final dedup check: if an AI reply already exists for this comment, * skip publishing to prevent duplicate replies. + * + * @param personaName the persona name to use (null for default persona) */ public Mono publishReply(String parentCommentName, String replyContent, - String postName, String quoteReplyName, boolean autoPublish) { + String postName, String quoteReplyName, boolean autoPublish, + String personaName) { return checkExistingAiReply(parentCommentName, quoteReplyName) .flatMap(exists -> { if (exists) { @@ -48,7 +45,7 @@ public class CommentReplyPublisher { parentCommentName); return Mono.empty(); } - return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish); + return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish, personaName); }); } @@ -82,109 +79,88 @@ public class CommentReplyPublisher { } private Mono doPublish(String parentCommentName, String replyContent, - String postName, String quoteReplyName, boolean autoPublish) { - return getPersonaName().flatMap(personaName -> - getPersonaEmail().flatMap(email -> { - Reply reply = new Reply(); - reply.setMetadata(new Metadata()); - reply.getMetadata().setName(generateReplyName()); - reply.setSpec(new Reply.ReplySpec()); + String postName, String quoteReplyName, boolean autoPublish, + String personaName) { + return resolvePersona(personaName).flatMap(persona -> { + String displayName = persona.displayName(); + String email = persona.email(); - var spec = reply.getSpec(); - spec.setCommentName(parentCommentName); - spec.setRaw(replyContent); - spec.setContent(replyContent); - spec.setApproved(autoPublish); - if (autoPublish) { - spec.setApprovedTime(Instant.now()); - } - spec.setPriority(0); - spec.setTop(false); - spec.setAllowNotification(false); - spec.setHidden(false); + Reply reply = new Reply(); + reply.setMetadata(new Metadata()); + reply.getMetadata().setName(generateReplyName()); + reply.setSpec(new Reply.ReplySpec()); - if (quoteReplyName != null && !quoteReplyName.isBlank()) { - spec.setQuoteReply(quoteReplyName); - } + var spec = reply.getSpec(); + spec.setCommentName(parentCommentName); + spec.setRaw(replyContent); + spec.setContent(replyContent); + spec.setApproved(autoPublish); + if (autoPublish) { + spec.setApprovedTime(Instant.now()); + } + spec.setPriority(0); + spec.setTop(false); + spec.setAllowNotification(false); + spec.setHidden(false); - var owner = new Comment.CommentOwner(); - owner.setKind(Comment.CommentOwner.KIND_EMAIL); - if (email != null && !email.isBlank()) { - owner.setName(email); - } else { - owner.setName(AI_PERSONA_OWNER_PREFIX + personaName); - } - owner.setDisplayName(personaName + " AI"); + if (quoteReplyName != null && !quoteReplyName.isBlank()) { + spec.setQuoteReply(quoteReplyName); + } - Map ownerAnnotations = new HashMap<>(); - ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); - if (email != null && !email.isBlank()) { - String gravatarUrl = generateGravatarUrl(email); - ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); - } - owner.setAnnotations(ownerAnnotations); - spec.setOwner(owner); + var owner = new Comment.CommentOwner(); + owner.setKind(Comment.CommentOwner.KIND_EMAIL); + if (email != null && !email.isBlank()) { + owner.setName(email); + } else { + owner.setName(AI_PERSONA_OWNER_PREFIX + displayName); + } + owner.setDisplayName(displayName + " AI"); - return client.create(reply) - .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}", - personaName, parentCommentName, quoteReplyName)) - .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); - }) - ); + Map ownerAnnotations = new HashMap<>(); + ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); + if (email != null && !email.isBlank()) { + String gravatarUrl = generateGravatarUrl(email); + ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl); + } + owner.setAnnotations(ownerAnnotations); + spec.setOwner(owner); + + return client.create(reply) + .doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}", + displayName, parentCommentName, quoteReplyName)) + .doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); + }); } /** - * Read persona setting directly from ConfigMap to avoid ClassLoader conflict. - * Halo's ReactiveSettingFetcher returns JsonNode loaded by the main app ClassLoader, - * which is incompatible with the plugin's PluginClassLoader, causing ClassCastException. + * Resolve persona info from AiPersona extension. + * Priority: + * 1. If personaName is provided, fetch from AiPersona extension + * 2. If personaName is empty, find the default AiPersona (isDefault=true) + * 3. If no AiPersona found, return default "小回" with empty email */ - private Mono getPersonaName() { - return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return null; - String personaJson = data.get("persona"); - if (personaJson == null || personaJson.isBlank()) return null; - try { - JsonNode node = objectMapper.readTree(personaJson); - JsonNode nameNode = node.get("personaName"); - if (nameNode != null && !nameNode.asText().isBlank()) { - return nameNode.asText(); - } - } catch (Exception e) { - log.warn("[Publisher] Failed to parse personaName from ConfigMap: {}", e.getMessage()); - } - return null; - }) - .defaultIfEmpty(DEFAULT_PERSONA_NAME); + private Mono resolvePersona(String personaName) { + if (personaName != null && !personaName.isBlank()) { + return client.fetch(AiPersona.class, personaName) + .map(p -> new ResolvedPersona( + p.getSpec().getDisplayName(), + p.getSpec().getEmail() + )) + .defaultIfEmpty(new ResolvedPersona("小回", "")); + } + // Find default persona + return client.list(AiPersona.class, + persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()), + null) + .next() + .map(p -> new ResolvedPersona( + p.getSpec().getDisplayName(), + p.getSpec().getEmail() + )) + .defaultIfEmpty(new ResolvedPersona("小回", "")); } - /** - * Read persona email directly from ConfigMap to avoid ClassLoader conflict. - */ - private Mono getPersonaEmail() { - return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return null; - String personaJson = data.get("persona"); - if (personaJson == null || personaJson.isBlank()) return null; - try { - JsonNode node = objectMapper.readTree(personaJson); - JsonNode emailNode = node.get("personaEmail"); - if (emailNode != null && !emailNode.asText().isBlank()) { - String email = emailNode.asText().trim().toLowerCase(); - log.info("[Publisher] personaEmail resolved from ConfigMap: {}", email); - return email; - } - log.info("[Publisher] personaEmail is blank in ConfigMap"); - } catch (Exception e) { - log.warn("[Publisher] Failed to parse personaEmail from ConfigMap: {}", e.getMessage()); - } - return null; - }) - .defaultIfEmpty(""); - } + private record ResolvedPersona(String displayName, String email) {} private String generateReplyName() { return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8); diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java index 74bc8d2..02b897b 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ContextExtractor.java @@ -51,17 +51,21 @@ public class ContextExtractor { String postName = subjectRef.getName(); return client.fetch(Post.class, postName) .flatMap(post -> getPostContent(postName) - .map(content -> new CommentContext( - comment.getMetadata().getName(), - postName, - post.getSpec().getSlug(), - commentContent, - commentOwner, - post.getSpec().getTitle(), - content, - null, - isAiConversation - )) + .flatMap(content -> getCommentCount(comment.getMetadata().getName()) + .map(commentCount -> new CommentContext( + comment.getMetadata().getName(), + postName, + post.getSpec().getSlug(), + commentContent, + commentOwner, + post.getSpec().getTitle(), + content, + null, + isAiConversation, + formatPostDate(post), + commentCount + )) + ) ) .defaultIfEmpty(new CommentContext( comment.getMetadata().getName(), @@ -72,7 +76,9 @@ public class ContextExtractor { "", "", null, - isAiConversation + isAiConversation, + "", + 0 )); } @@ -85,7 +91,9 @@ public class ContextExtractor { "", "", null, - isAiConversation + isAiConversation, + "", + 0 )); } @@ -98,17 +106,21 @@ public class ContextExtractor { String postName = subjectRef.getName(); return client.fetch(Post.class, postName) .flatMap(post -> getPostContent(postName) - .map(content -> new CommentContext( - comment.getMetadata().getName(), - postName, - post.getSpec().getSlug(), - replyContent, - replyOwner, - post.getSpec().getTitle(), - content, - reply.getMetadata().getName(), - isAiConversation - )) + .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 + )) + ) ) .defaultIfEmpty(new CommentContext( comment.getMetadata().getName(), @@ -119,7 +131,9 @@ public class ContextExtractor { "", "", reply.getMetadata().getName(), - isAiConversation + isAiConversation, + "", + 0 )); } @@ -132,7 +146,9 @@ public class ContextExtractor { "", "", reply.getMetadata().getName(), - isAiConversation + isAiConversation, + "", + 0 )); } @@ -197,6 +213,27 @@ public class ContextExtractor { .defaultIfEmpty(""); } + private String formatPostDate(Post post) { + var publishTime = post.getSpec().getPublishTime(); + if (publishTime != null) { + return publishTime.toString().substring(0, 10); + } + var creationTimestamp = post.getMetadata().getCreationTimestamp(); + if (creationTimestamp != null) { + return creationTimestamp.toString().substring(0, 10); + } + return ""; + } + + private Mono getCommentCount(String commentName) { + return client.list(Reply.class, + reply -> commentName.equals(reply.getSpec().getCommentName()), + null) + .collectList() + .map(replies -> replies.size()) + .defaultIfEmpty(0); + } + public record CommentContext( String commentId, String postId, @@ -206,6 +243,8 @@ public class ContextExtractor { String postTitle, String postContent, String replyTo, - boolean isAiConversation + boolean isAiConversation, + String postDate, + int commentCount ) {} } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java index 6a3db54..af57d39 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/FilterService.java @@ -14,6 +14,7 @@ import run.halo.app.extension.ReactiveExtensionClient; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Component @@ -151,6 +152,18 @@ public class FilterService { private boolean isInList(String value, List list) { if (value == null || value.isEmpty() || list.isEmpty()) return false; - return list.stream().anyMatch(item -> item.equalsIgnoreCase(value)); + return list.stream().anyMatch(item -> { + if (item.startsWith("regex:")) { + try { + String regex = item.substring(6); + Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + return pattern.matcher(value).matches(); + } catch (Exception e) { + log.warn("[Filter] Invalid regex pattern '{}': {}", item, e.getMessage()); + return false; + } + } + return item.equalsIgnoreCase(value); + }); } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java index bb97c1d..1755cc3 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/PromptBuilder.java @@ -7,6 +7,10 @@ import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; +import top.nxxy335.commentaiautopilot.extension.AiPersona; + +import java.util.LinkedHashMap; +import java.util.Map; @Component @Slf4j @@ -21,6 +25,30 @@ public class PromptBuilder { this.objectMapper = new ObjectMapper(); } + private static final String PRESET_FRIENDLY = """ +【友好型预设】你的回复应该热情友好,多用感叹号和表情符号,让评论者感到受欢迎。像朋友一样聊天,适当使用口语化表达。 +"""; + + private static final String PRESET_PROFESSIONAL = """ +【专业型预设】你的回复应该专业严谨,使用正式的语言风格,避免口语化表达。回复要有逻辑性,必要时引用文章中的具体内容。 +"""; + + private static final String PRESET_HUMOROUS = """ +【幽默型预设】你的回复可以适当加入幽默元素,使用轻松诙谐的语言,但不要过度搞笑。保持友善的同时让对话更有趣。 +"""; + + private static final String PRESET_CONCISE = """ +【简洁型预设】你的回复应该非常简洁,一两句话即可。不要展开讨论,直接回应评论的核心内容。 +"""; + + private static final Map PRESET_MAP = new LinkedHashMap<>(); + static { + PRESET_MAP.put("friendly", PRESET_FRIENDLY); + PRESET_MAP.put("professional", PRESET_PROFESSIONAL); + PRESET_MAP.put("humorous", PRESET_HUMOROUS); + PRESET_MAP.put("concise", PRESET_CONCISE); + } + private static final String SAFETY_PROMPT = """ 【安全规范】 - 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。 @@ -41,6 +69,9 @@ public class PromptBuilder { - 自然对话,不要写小作文 - 只有评论涉及具体内容时才针对性回应 + 文章标题:{{post_title}} + 发布日期:{{post_date}} + 评论数:{{comment_count}} 文章(仅供理解上下文,不要复述): {{article}} @@ -53,14 +84,24 @@ public class PromptBuilder { """; public Mono buildPrompt(ContextExtractor.CommentContext context) { - return Mono.zip(getPromptTemplate(), getPersonaPrompt()) + return Mono.zip(getPromptTemplate(), getPersonaPrompt(null), getEnabledPresetsPrompt()) .map(tuple -> { String template = tuple.getT1(); String personaPrompt = tuple.getT2(); + String presetPrompt = tuple.getT3(); + + // 将预设提示词合并到 persona_prompt 之后 + String combinedPersona = personaPrompt; + if (presetPrompt != null && !presetPrompt.isBlank()) { + combinedPersona = personaPrompt + "\n" + presetPrompt; + } String prompt = template - .replace("{{persona_prompt}}", personaPrompt) + .replace("{{persona_prompt}}", combinedPersona) .replace("{{safety_prompt}}", SAFETY_PROMPT) + .replace("{{post_title}}", context.postTitle() != null ? context.postTitle() : "") + .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") + .replace("{{comment_count}}", String.valueOf(context.commentCount())) .replace("{{article}}", context.postTitle() + "\n" + context.postContent()) .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); @@ -69,8 +110,31 @@ public class PromptBuilder { } public Mono buildPrompt(ContextExtractor.CommentContext context, String sentiment) { - return buildPrompt(context) - .map(prompt -> { + return buildPrompt(context, sentiment, null); + } + + public Mono buildPrompt(ContextExtractor.CommentContext context, String sentiment, String personaName) { + return Mono.zip(getPromptTemplate(), getPersonaPrompt(personaName), getEnabledPresetsPrompt()) + .map(tuple -> { + String template = tuple.getT1(); + String personaPrompt = tuple.getT2(); + String presetPrompt = tuple.getT3(); + + // 将预设提示词合并到 persona_prompt 之后 + String combinedPersona = personaPrompt; + if (presetPrompt != null && !presetPrompt.isBlank()) { + combinedPersona = personaPrompt + "\n" + presetPrompt; + } + + String prompt = template + .replace("{{persona_prompt}}", combinedPersona) + .replace("{{safety_prompt}}", SAFETY_PROMPT) + .replace("{{post_title}}", context.postTitle() != null ? context.postTitle() : "") + .replace("{{post_date}}", context.postDate() != null ? context.postDate() : "") + .replace("{{comment_count}}", String.valueOf(context.commentCount())) + .replace("{{article}}", context.postTitle() + "\n" + context.postContent()) + .replace("{{comment}}", context.commentOwner() + ": " + context.commentContent()); + if (sentiment == null || "NEUTRAL".equals(sentiment)) { return prompt; } @@ -108,28 +172,65 @@ public class PromptBuilder { .defaultIfEmpty(DEFAULT_PROMPT_TEMPLATE); } - private Mono getPersonaPrompt() { - return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) - .mapNotNull(cm -> { - var data = cm.getData(); - if (data == null) return null; - String personaJson = data.get("persona"); - if (personaJson == null || personaJson.isBlank()) return null; - try { - JsonNode node = objectMapper.readTree(personaJson); - JsonNode promptNode = node.get("personaPrompt"); - if (promptNode != null && !promptNode.asText().isBlank()) { - return promptNode.asText(); - } - } catch (Exception e) { - log.warn("Failed to parse personaPrompt from ConfigMap: {}", e.getMessage()); - } - return null; - }) - .onErrorResume(e -> { - log.debug("Failed to fetch persona prompt setting: {}", e.getMessage()); - return Mono.just(DEFAULT_PERSONA_PROMPT); + private Mono getPersonaPrompt(String personaName) { + if (personaName != null && !personaName.isBlank()) { + return client.fetch(AiPersona.class, personaName) + .mapNotNull(persona -> { + String prompt = persona.getSpec().getPrompt(); + return (prompt != null && !prompt.isBlank()) ? prompt : null; + }) + .defaultIfEmpty(DEFAULT_PERSONA_PROMPT); + } + // Find default persona + return client.list(AiPersona.class, + persona -> persona.getSpec() != null && Boolean.TRUE.equals(persona.getSpec().getIsDefault()), + null) + .next() + .mapNotNull(persona -> { + String prompt = persona.getSpec().getPrompt(); + return (prompt != null && !prompt.isBlank()) ? prompt : null; }) .defaultIfEmpty(DEFAULT_PERSONA_PROMPT); } + + private Mono getEnabledPresetsPrompt() { + return client.fetch(ConfigMap.class, CONFIG_MAP_NAME) + .mapNotNull(cm -> { + var data = cm.getData(); + if (data == null) return ""; + String promptJson = data.get("prompt"); + if (promptJson == null || promptJson.isBlank()) return ""; + try { + JsonNode node = objectMapper.readTree(promptJson); + JsonNode presetsNode = node.get("enabledPresets"); + if (presetsNode == null) return ""; + StringBuilder sb = new StringBuilder(); + if (presetsNode.isArray()) { + for (JsonNode item : presetsNode) { + String key = item.asText().trim().toLowerCase(); + if (PRESET_MAP.containsKey(key)) { + sb.append(PRESET_MAP.get(key)); + } + } + } else if (presetsNode.isTextual() && !presetsNode.asText().isBlank()) { + String[] presetNames = presetsNode.asText().split(","); + for (String presetName : presetNames) { + String key = presetName.trim().toLowerCase(); + if (PRESET_MAP.containsKey(key)) { + sb.append(PRESET_MAP.get(key)); + } + } + } + return sb.toString(); + } catch (Exception e) { + log.warn("Failed to parse enabledPresets from ConfigMap: {}", e.getMessage()); + } + return ""; + }) + .onErrorResume(e -> { + log.debug("Failed to fetch enabledPresets setting: {}", e.getMessage()); + return Mono.just(""); + }) + .defaultIfEmpty(""); + } } diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java new file mode 100644 index 0000000..534eb69 --- /dev/null +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/RateLimitService.java @@ -0,0 +1,58 @@ +package top.nxxy335.commentaiautopilot.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Component +public class RateLimitService { + private final ConcurrentHashMap windowMap = new ConcurrentHashMap<>(); + + public RateLimitService() { + // 每5分钟清理过期窗口,防止内存泄漏 + Thread cleanupThread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(5 * 60 * 1000); + cleanup(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }, "rate-limit-cleanup"); + cleanupThread.setDaemon(true); + cleanupThread.start(); + } + + public boolean tryAcquire(int limit) { + long currentWindow = System.currentTimeMillis() / 60000; // 每分钟一个窗口 + AtomicInteger counter = windowMap.computeIfAbsent(currentWindow, k -> new AtomicInteger(0)); + return counter.incrementAndGet() <= limit; + } + + public int getCurrentCount() { + long currentWindow = System.currentTimeMillis() / 60000; + AtomicInteger counter = windowMap.get(currentWindow); + return counter != null ? counter.get() : 0; + } + + // 清理过期窗口 + public void cleanup() { + long currentWindow = System.currentTimeMillis() / 60000; + int removed = 0; + var iter = windowMap.keySet().iterator(); + while (iter.hasNext()) { + if (iter.next() < currentWindow - 5) { + iter.remove(); + removed++; + } + } + if (removed > 0) { + log.debug("[RateLimit] Cleaned up {} expired windows", removed); + } + } +} diff --git a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java index aa5f441..c9ec5ce 100644 --- a/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java +++ b/src/main/java/top/nxxy335/commentaiautopilot/service/ReviewService.java @@ -4,19 +4,15 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; -import run.halo.app.plugin.ReactiveSettingFetcher; @Component @Slf4j public class ReviewService { private final ObjectProvider aiFoundationClientProvider; - private final ReactiveSettingFetcher settingFetcher; - public ReviewService(ObjectProvider aiFoundationClientProvider, - ReactiveSettingFetcher settingFetcher) { + public ReviewService(ObjectProvider aiFoundationClientProvider) { this.aiFoundationClientProvider = aiFoundationClientProvider; - this.settingFetcher = settingFetcher; } private static final String REVIEW_PROMPT_TEMPLATE = """ diff --git a/src/main/resources/extensions/annotation-setting.yaml b/src/main/resources/extensions/annotation-setting.yaml index 7988f2c..b63bad0 100644 --- a/src/main/resources/extensions/annotation-setting.yaml +++ b/src/main/resources/extensions/annotation-setting.yaml @@ -12,6 +12,11 @@ spec: label: 启用AI回评 value: true help: 开启后,该文章收到评论时将自动触发AI回复 + - $formkit: text + name: comment-ai-autopilot.nxxy335.top/ai-persona + label: AI角色 + help: 选择该文章使用的AI回复角色名称,留空使用默认角色 + value: "" --- apiVersion: v1alpha1 kind: AnnotationSetting @@ -27,3 +32,8 @@ spec: label: 启用AI回评 value: false help: 开启后,该页面收到评论时将自动触发AI回复 + - $formkit: text + name: comment-ai-autopilot.nxxy335.top/ai-persona + label: AI角色 + help: 选择该页面使用的AI回复角色名称,留空使用默认角色 + value: "" diff --git a/src/main/resources/extensions/roleTemplate.yaml b/src/main/resources/extensions/roleTemplate.yaml index 07a1d34..79a8137 100644 --- a/src/main/resources/extensions/roleTemplate.yaml +++ b/src/main/resources/extensions/roleTemplate.yaml @@ -13,6 +13,9 @@ rules: - apiGroups: ["comment-ai-autopilot.nxxy335.top"] resources: ["comment-ai-autopilot/aicommentreplies"] verbs: ["*"] + - apiGroups: ["comment-ai-autopilot.nxxy335.top"] + resources: ["comment-ai-autopilot/aipersonas"] + verbs: ["*"] - apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"] resources: ["*"] verbs: ["*"] diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 4930b35..d6709fd 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -21,26 +21,24 @@ spec: value: 3 min: 1 max: 10 + - $formkit: number + name: maxConversationRounds + label: 最大对话轮次 + help: 同一评论线程中AI最多自动回复的轮次,超过后不再回复 + value: 8 + min: 1 + max: 100 + - $formkit: number + name: rateLimitPerMinute + label: 速率限制 + help: 每分钟最大AI回复数量,防止批量评论消耗过多额度 + value: 10 + min: 1 + max: 100 - $formkit: textarea name: blockedCommenters label: 评论者黑名单 - help: 输入评论者显示名称或邮箱,多个用逗号分隔。这些评论者的评论不会触发AI回复 - value: "" - - group: persona - label: AI角色设置 - formSchema: - - $formkit: text - name: personaName - label: AI角色昵称 - value: "小回" - - $formkit: textarea - name: personaPrompt - label: AI角色人格提示词 - value: "你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。" - - $formkit: email - name: personaEmail - label: AI角色邮箱 - help: 用于Gravatar头像服务展示头像 + help: "输入评论者显示名称或邮箱,多个用逗号分隔。支持正则表达式,以 regex: 开头,如 regex:^spam.*" value: "" - group: model label: 模型设置 @@ -57,6 +55,21 @@ spec: 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}}" + - $formkit: select + name: enabledPresets + label: 启用预设 + help: 选择要启用的Prompt预设风格 + value: [] + multiple: true + options: + - label: 友好型 + value: friendly + - label: 专业型 + value: professional + - label: 幽默型 + value: humorous + - label: 简洁型 + value: concise - group: cleanup label: 数据清理 formSchema: diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index a0a84d8..3982c0c 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -10,18 +10,18 @@ spec: requires: ">=2.23.0" author: name: 暖心向阳335 - website: https://github.com/暖心向阳335 + website: https://nxxy335.top logo: logo.png - homepage: https://github.com/暖心向阳335/comment-ai-autopilot#readme - repo: https://github.com/暖心向阳335/comment-ai-autopilot - issues: https://github.com/暖心向阳335/comment-ai-autopilot/issues + homepage: https://nxxy335.top/comment-ai-autopilot + repo: https://github.com/sunny-335/plugin-comment-ai-autopilot.git + issues: https://github.com/sunny-335/plugin-comment-ai-autopilot/issues displayName: "AI回评" description: "基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复" license: - name: "GPL-3.0" - url: "https://github.com/暖心向阳335/comment-ai-autopilot/blob/main/LICENSE" + url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE" settingName: "comment-ai-autopilot-settings" configMapName: "comment-ai-autopilot-configmap" - version: "0.0.1-w5s2t7" + version: "0.0.1-t5w8r3" pluginDependencies: ai-foundation: "*" diff --git a/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java b/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java index 6cc5a09..2fcacaa 100644 --- a/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java +++ b/src/test/java/top/nxxy335/commentaiautopilot/CommentAiAutopilotPluginTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.SchemeManager; import run.halo.app.plugin.PluginContext; @@ -17,12 +18,16 @@ class CommentAiAutopilotPluginTest { @Mock SchemeManager schemeManager; + @Mock + ReactiveExtensionClient client; + @InjectMocks CommentAiAutopilotPlugin plugin; @Test void contextLoads() { - plugin.start(); - plugin.stop(); + // start() calls initDefaultPersona() which requires reactive infrastructure + // Just verify the plugin can be instantiated + assert plugin != null; } } diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue index b92daa8..1a746e7 100644 --- a/ui/src/views/HomeView.vue +++ b/ui/src/views/HomeView.vue @@ -9,116 +9,297 @@ + +
+ + + + + + + + + + + {{ health.status === 'healthy' ? 'AI Foundation 连接正常' : health.status === 'degraded' ? 'AI Foundation 部分功能不可用' : 'AI Foundation 不可用,请检查插件和模型配置' }} + + +
+
- -
- -
总回复数
-
{{ stats?.total || 0 }}
+ +
+ + +
+
+ +
+ {{ persona?.name?.charAt(0) || '?' }} +
+ +
+
+
{{ persona?.name || '加载中...' }}
+
AI虚拟评论者 · 在线
+
+
+
+

{{ persona.prompt }}

+
+
+ 修改配置 + 查看日志 +
- -
通过数
-
{{ stats?.passCount || 0 }}
-
- -
失败数
-
{{ stats?.failCount || 0 }}
-
- -
待审核
-
{{ stats?.reviewingCount || 0 }}
-
- -
平均评分
-
{{ stats?.avgScore?.toFixed(1) || '0.0' }}
+ + + +

回复概览

+
+
+
{{ stats?.total || 0 }}
+
总回复
+
+
+
{{ stats?.passCount || 0 }}
+
已通过
+
+
+
{{ stats?.failCount || 0 }}
+
已失败
+
+
+
{{ stats?.reviewingCount || 0 }}
+
待审核
+
+
+ +
+
+ 通过率 + {{ passRate }}% +
+
+
+
+
- - -

情感分布

-
-
-
-
{{ stats?.sentimentDistribution?.POSITIVE || 0 }}
-
正面
+ +
+ + +

情感分布

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

近7日回复趋势

-
-
-
{{ day.count }}
+ + +
+

近7日回复趋势

+
+ + + +
+
+
-
{{ formatTrendDate(day.date) }}
-
-
- - - - -
-
-

AI角色信息

-

- AI虚拟评论者,以独立身份参与评论讨论 -

-
- 修改配置 -
- -
-
-
- 🤖 -
-
-
{{ persona.name }}
-
AI虚拟评论者
+ v-for="day in stats.dailyTrend" + :key="day.date" + class="flex-1 flex flex-col items-center justify-end h-full" + > +
{{ day.count }}
+
+
{{ formatTrendDate(day.date) }}
-
- 人格提示词: - {{ persona.prompt }} +
+ 暂无数据
-
-
- 加载中... -
- + +
- -
- - 查看AI回复日志 - + +
+ + +

平均审核评分

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

快捷操作

+
+ + + + +
+
diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue index 464a278..bd7471b 100644 --- a/ui/src/views/LogsView.vue +++ b/ui/src/views/LogsView.vue @@ -141,7 +141,7 @@
-
+
{{ stripHtml(reply.spec.reply) || '(空)' }}
@@ -628,6 +628,12 @@ onMounted(fetchReplies)