feat: 多AI角色支持、Bug修复、文档更新

This commit is contained in:
sunny-335
2026-06-15 12:05:46 +08:00
parent ef3cdf4bdc
commit 6f1cb4b037
27 changed files with 2400 additions and 755 deletions
+19 -14
View File
@@ -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)
## 许可证
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
}
group 'top.nxxy335.commentaiautopilot'
version '0.0.1-w5s2t7'
version '0.0.1-t5w8r3'
repositories {
mavenCentral()
+1
View File
@@ -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" },
+26 -5
View File
@@ -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}},回复时请考虑时效性。
```
@@ -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;
/**
* <p>Plugin main class to manage the lifecycle of the plugin.</p>
@@ -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.<AiCommentReply, String>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));
}
}
@@ -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<AiFoundationClient> 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<AiFoundationClient> 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<ServerResponse> 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<AiCommentReply> 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<LocalDate, Long> 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<DailyCount> 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<ServerResponse> 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("小回", "", "");
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 {
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("小回", "", "");
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<String> getPersonaNameFromComment(String commentName) {
return client.fetch(Comment.class, commentName)
.flatMap(comment -> {
var subjectRef = comment.getSpec().getSubjectRef();
if (subjectRef == null || !"Post".equals(subjectRef.getKind())) {
return Mono.justOrEmpty(null);
}
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.mapNotNull(post -> {
var annotations = post.getMetadata().getAnnotations();
if (annotations != null) {
String persona = annotations.get(AI_PERSONA_ANNOTATION);
if (persona != null && !persona.isBlank()) {
return persona;
}
}
return null;
});
})
.defaultIfEmpty("");
}
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
// 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<ServerResponse> 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<ServerResponse> listPersonas(ServerRequest request) {
return client.listAll(AiPersona.class, ListOptions.builder().build(), Sort.unsorted())
.collectList()
.flatMap(personas -> ServerResponse.ok().bodyValue(personas));
}
private Mono<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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<ServerResponse> 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());
}
}
@@ -56,5 +56,8 @@ public class AiCommentReply extends AbstractExtension {
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
private String sentiment;
@Schema(description = "使用的AI角色名称")
private String personaName;
}
}
@@ -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;
}
}
@@ -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<Reconciler.Request> {
private final ReactiveExtensionClient client;
@Override
public Result reconcile(Request request) {
return new Result(false, null);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new AiPersona())
.syncAllOnStart(false)
.build();
}
}
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<Reconciler.Request> {
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<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
@@ -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<Reconciler.Request> {
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<Reconciler.Request> {
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<Reconciler.Request> {
.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<String, String> annotations) {
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
}
@@ -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;
@@ -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<Void> processComment(String commentName, String replyName, boolean isAiConversation) {
public Mono<Void> 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,6 +86,12 @@ public class AiReplyOrchestrator {
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
return Mono.empty();
}
return getRateLimit()
.flatMap(rateLimit -> {
if (!rateLimitService.tryAcquire(rateLimit)) {
log.info("[Orchestrator] 速率限制,跳过: {}", commentName);
return Mono.empty();
}
return filterService.shouldProcess(commentName)
.flatMap(shouldProcess -> {
if (!shouldProcess) {
@@ -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);
});
}
// 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);
return doProcess(commentName, replyName, isAiConversation, personaName);
});
})
);
});
});
})
@@ -117,16 +140,17 @@ public class AiReplyOrchestrator {
.then();
}
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation) {
private Mono<Void> 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<Void> 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<Void> 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<Void> 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<Void> 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<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
private Mono<Integer> 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<Integer> 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<Integer> 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<AiCommentReply> 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()));
@@ -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<Reply> 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,9 +79,12 @@ public class CommentReplyPublisher {
}
private Mono<Reply> doPublish(String parentCommentName, String replyContent,
String postName, String quoteReplyName, boolean autoPublish) {
return getPersonaName().flatMap(personaName ->
getPersonaEmail().flatMap(email -> {
String postName, String quoteReplyName, boolean autoPublish,
String personaName) {
return resolvePersona(personaName).flatMap(persona -> {
String displayName = persona.displayName();
String email = persona.email();
Reply reply = new Reply();
reply.setMetadata(new Metadata());
reply.getMetadata().setName(generateReplyName());
@@ -112,9 +112,9 @@ public class CommentReplyPublisher {
if (email != null && !email.isBlank()) {
owner.setName(email);
} else {
owner.setName(AI_PERSONA_OWNER_PREFIX + personaName);
owner.setName(AI_PERSONA_OWNER_PREFIX + displayName);
}
owner.setDisplayName(personaName + " AI");
owner.setDisplayName(displayName + " AI");
Map<String, String> ownerAnnotations = new HashMap<>();
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
@@ -127,64 +127,40 @@ public class CommentReplyPublisher {
return client.create(reply)
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}",
personaName, parentCommentName, quoteReplyName))
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<String> 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();
private Mono<ResolvedPersona> resolvePersona(String personaName) {
if (personaName != null && !personaName.isBlank()) {
return client.fetch(AiPersona.class, personaName)
.map(p -> new ResolvedPersona(
p.getSpec().getDisplayName(),
p.getSpec().getEmail()
))
.defaultIfEmpty(new ResolvedPersona("小回", ""));
}
} catch (Exception e) {
log.warn("[Publisher] Failed to parse personaName from ConfigMap: {}", e.getMessage());
}
return null;
})
.defaultIfEmpty(DEFAULT_PERSONA_NAME);
// 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<String> 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);
@@ -51,7 +51,8 @@ public class ContextExtractor {
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName)
.map(content -> new CommentContext(
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
.map(commentCount -> new CommentContext(
comment.getMetadata().getName(),
postName,
post.getSpec().getSlug(),
@@ -60,9 +61,12 @@ public class ContextExtractor {
post.getSpec().getTitle(),
content,
null,
isAiConversation
isAiConversation,
formatPostDate(post),
commentCount
))
)
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
postName,
@@ -72,7 +76,9 @@ public class ContextExtractor {
"",
"",
null,
isAiConversation
isAiConversation,
"",
0
));
}
@@ -85,7 +91,9 @@ public class ContextExtractor {
"",
"",
null,
isAiConversation
isAiConversation,
"",
0
));
}
@@ -98,7 +106,8 @@ public class ContextExtractor {
String postName = subjectRef.getName();
return client.fetch(Post.class, postName)
.flatMap(post -> getPostContent(postName)
.map(content -> new CommentContext(
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
.map(commentCount -> new CommentContext(
comment.getMetadata().getName(),
postName,
post.getSpec().getSlug(),
@@ -107,9 +116,12 @@ public class ContextExtractor {
post.getSpec().getTitle(),
content,
reply.getMetadata().getName(),
isAiConversation
isAiConversation,
formatPostDate(post),
commentCount
))
)
)
.defaultIfEmpty(new CommentContext(
comment.getMetadata().getName(),
postName,
@@ -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<Integer> 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
) {}
}
@@ -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<String> 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);
});
}
}
@@ -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<String, String> 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<String> 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<String> buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
return buildPrompt(context)
.map(prompt -> {
return buildPrompt(context, sentiment, null);
}
public Mono<String> 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<String> 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<String> 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<String> 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("");
}
}
@@ -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<Long, AtomicInteger> 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);
}
}
}
@@ -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<AiFoundationClient> aiFoundationClientProvider;
private final ReactiveSettingFetcher settingFetcher;
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider,
ReactiveSettingFetcher settingFetcher) {
public ReviewService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
this.aiFoundationClientProvider = aiFoundationClientProvider;
this.settingFetcher = settingFetcher;
}
private static final String REVIEW_PROMPT_TEMPLATE = """
@@ -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: ""
@@ -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: ["*"]
+30 -17
View File
@@ -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:
+6 -6
View File
@@ -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: "*"
@@ -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;
}
}
+336 -86
View File
@@ -9,116 +9,297 @@
</template>
</VPageHeader>
<!-- Health Banner -->
<div v-if="health && healthVisible" class="mx-4 mt-2 flex items-center gap-2 rounded-lg px-4 py-2.5"
:class="{
'bg-green-50 border border-green-200 text-green-700': health.status === 'healthy',
'bg-yellow-50 border border-yellow-200 text-yellow-700': health.status === 'degraded',
'bg-red-50 border border-red-200 text-red-700': health.status === 'unhealthy',
}"
>
<svg v-if="health.status === 'healthy'" class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else-if="health.status === 'degraded'" class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<svg v-else class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-sm flex-1">
{{ health.status === 'healthy' ? 'AI Foundation 连接正常' : health.status === 'degraded' ? 'AI Foundation 部分功能不可用' : 'AI Foundation 不可用,请检查插件和模型配置' }}
</span>
<button class="shrink-0 hover:opacity-70 transition-opacity" @click="healthVisible = false">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="m-4">
<!-- Stats Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">总回复数</div>
<div class="mt-1 text-2xl font-bold">{{ stats?.total || 0 }}</div>
<!-- Top: AI Persona + Stats Overview -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- AI Persona Card -->
<VCard :body-class="['!p-5']">
<div class="flex items-center gap-4">
<div class="relative">
<img
v-if="persona?.avatar"
:src="persona.avatar"
:alt="persona?.name"
class="h-14 w-14 rounded-full object-cover ring-2 ring-blue-100"
/>
<div
v-else
class="h-14 w-14 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xl font-bold shadow-sm"
>
{{ persona?.name?.charAt(0) || '?' }}
</div>
<span class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full bg-green-400 ring-2 ring-white"></span>
</div>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold text-gray-900 truncate">{{ persona?.name || '加载中...' }}</div>
<div class="text-xs text-gray-400 mt-0.5">AI虚拟评论者 · 在线</div>
</div>
</div>
<div v-if="persona?.prompt" class="mt-3 bg-gray-50 rounded-md px-3 py-2">
<p class="text-xs text-gray-500 line-clamp-2 leading-relaxed">{{ persona.prompt }}</p>
</div>
<div class="mt-3 flex gap-2">
<VButton size="sm" type="secondary" @click="openSettings">修改配置</VButton>
<VButton size="sm" @click="$router.push({ name: 'CommentAiAutopilotLogs' })">查看日志</VButton>
</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">通过数</div>
<div class="mt-1 text-2xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">失败数</div>
<div class="mt-1 text-2xl font-bold text-red-600">{{ stats?.failCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">待审核</div>
<div class="mt-1 text-2xl font-bold text-orange-500">{{ stats?.reviewingCount || 0 }}</div>
</VCard>
<VCard :body-class="['!p-4']">
<div class="text-sm text-gray-500">平均评分</div>
<div class="mt-1 text-2xl font-bold">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<!-- Stats Overview -->
<VCard :body-class="['!p-5']" class="lg:col-span-2">
<h3 class="text-sm font-medium text-gray-500 mb-4">回复概览</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">{{ stats?.total || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">总回复</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-green-600">{{ stats?.passCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">已通过</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-red-500">{{ stats?.failCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">已失败</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-amber-500">{{ stats?.reviewingCount || 0 }}</div>
<div class="text-xs text-gray-400 mt-1">待审核</div>
</div>
</div>
<!-- Pass Rate Bar -->
<div class="mt-4">
<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
<span>通过率</span>
<span>{{ passRate }}%</span>
</div>
<div class="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-green-400 to-green-500 rounded-full transition-all duration-500"
:style="{ width: passRate + '%' }"
></div>
</div>
</div>
</VCard>
</div>
<!-- Middle: Sentiment + Trend -->
<div class="grid grid-cols-1 gap-4 mt-4 lg:grid-cols-2">
<!-- Sentiment Distribution -->
<VCard class="mt-4" :body-class="['!p-4']">
<h3 class="text-base font-medium mb-3">情感分布</h3>
<div class="flex items-end gap-6">
<div class="flex flex-col items-center">
<div class="w-16 bg-green-100 rounded-t" :style="{ height: getSentimentBarHeight('POSITIVE') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</div>
<div class="text-xs text-gray-500">正面</div>
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-4">情感分布</h3>
<div class="space-y-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-green-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">正面</span>
<span class="font-medium text-green-600">{{ stats?.sentimentDistribution?.POSITIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-green-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('POSITIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-400 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">中性</span>
<span class="font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEUTRAL') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-red-500 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">负面</span>
<span class="font-medium text-red-500">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-red-400 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('NEGATIVE') + '%' }"
></div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-gray-300 shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700">未知</span>
<span class="font-medium text-gray-400">{{ stats?.sentimentDistribution?.UNKNOWN || 0 }}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full bg-gray-300 rounded-full transition-all duration-500"
:style="{ width: getSentimentPercent('UNKNOWN') + '%' }"
></div>
</div>
<div class="flex flex-col items-center">
<div class="w-16 bg-gray-200 rounded-t" :style="{ height: getSentimentBarHeight('NEUTRAL') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-gray-600">{{ stats?.sentimentDistribution?.NEUTRAL || 0 }}</div>
<div class="text-xs text-gray-500">中性</div>
</div>
<div class="flex flex-col items-center">
<div class="w-16 bg-red-100 rounded-t" :style="{ height: getSentimentBarHeight('NEGATIVE') + 'px' }"></div>
<div class="mt-1 text-sm font-medium text-red-600">{{ stats?.sentimentDistribution?.NEGATIVE || 0 }}</div>
<div class="text-xs text-gray-500">负面</div>
</div>
</div>
</VCard>
<!-- Daily Trend -->
<VCard class="mt-4" :body-class="['!p-4']">
<h3 class="text-base font-medium mb-3">近7日回复趋势</h3>
<div class="flex items-end gap-2 h-32">
<VCard :body-class="['!p-5']">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium text-gray-500">近7日回复趋势</h3>
<div class="inline-flex rounded-md border border-gray-200 overflow-hidden">
<button
class="px-2.5 py-1 text-xs transition-colors"
:class="range === '7' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '7'"
>
7
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === '30' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = '30'"
>
30
</button>
<button
class="px-2.5 py-1 text-xs border-l border-gray-200 transition-colors"
:class="range === 'all' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'"
@click="range = 'all'"
>
全部
</button>
</div>
</div>
<div v-if="stats?.dailyTrend?.length" class="flex items-end gap-3" style="height: 160px">
<div
v-for="day in (stats?.dailyTrend || [])"
v-for="day in stats.dailyTrend"
:key="day.date"
class="flex-1 flex flex-col items-center justify-end h-full"
>
<div class="text-xs text-gray-500 mb-1">{{ day.count }}</div>
<div class="text-xs text-gray-500 mb-1 font-medium">{{ day.count }}</div>
<div
class="w-full bg-blue-400 rounded-t transition-all"
class="w-full rounded-t-md transition-all duration-500"
:class="day.count > 0 ? 'bg-gradient-to-t from-blue-500 to-blue-400' : 'bg-gray-100'"
:style="{ height: getTrendBarHeight(day.count) + 'px' }"
></div>
<div class="text-[10px] text-gray-400 mt-1">{{ formatTrendDate(day.date) }}</div>
<div class="text-[10px] text-gray-400 mt-2 whitespace-nowrap">{{ formatTrendDate(day.date) }}</div>
</div>
</div>
<div v-else class="flex items-center justify-center text-sm text-gray-400" style="height: 160px">
暂无数据
</div>
</VCard>
</div>
<!-- Bottom: Score + Quick Actions -->
<div class="grid grid-cols-1 gap-4 mt-4 sm:grid-cols-2">
<!-- Avg Score -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">平均审核评分</h3>
<div class="flex items-center gap-4">
<div class="text-4xl font-bold" :class="scoreColor">{{ stats?.avgScore?.toFixed(1) || '0.0' }}</div>
<div class="flex-1">
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor"
:style="{ width: (stats?.avgScore || 0) * 10 + '%' }"
></div>
</div>
<div class="flex justify-between text-[10px] text-gray-300 mt-1">
<span>0</span>
<span>5</span>
<span>10</span>
</div>
</div>
</div>
</VCard>
<!-- AI Persona Section -->
<VCard class="mt-4" :body-class="['!p-4']">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium">AI角色信息</h3>
<p class="mt-1 text-sm text-gray-500">
AI虚拟评论者以独立身份参与评论讨论
</p>
</div>
<VButton @click="openSettings"> 修改配置 </VButton>
</div>
<div v-if="persona" class="mt-4 space-y-3">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-lg">
🤖
</div>
<div>
<div class="font-medium">{{ persona.name }}</div>
<div class="text-xs text-gray-400">AI虚拟评论者</div>
</div>
</div>
<div v-if="persona.prompt" class="flex items-start gap-2">
<span class="text-sm text-gray-500 shrink-0 whitespace-nowrap">人格提示词</span>
<span class="text-sm text-gray-600 line-clamp-3">{{ persona.prompt }}</span>
</div>
</div>
<div v-else class="mt-4 text-sm text-gray-400">
加载中...
<!-- Quick Actions -->
<VCard :body-class="['!p-5']">
<h3 class="text-sm font-medium text-gray-500 mb-3">快捷操作</h3>
<div class="grid grid-cols-2 gap-2">
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotLogs' })"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
回复日志
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="openSettings"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
插件设置
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="$router.push({ name: 'CommentAiAutopilotSettings' })"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
AI角色
</button>
<button
class="flex items-center gap-2 px-3 py-2.5 rounded-md bg-gray-50 hover:bg-gray-100 transition-colors text-sm text-gray-700"
@click="refreshData"
>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
刷新数据
</button>
</div>
</VCard>
<!-- Quick Links -->
<div class="mt-4 flex gap-4">
<VButton @click="$router.push({ name: 'CommentAiAutopilotLogs' })">
查看AI回复日志
</VButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { ref, computed, onMounted, watch } from "vue"
import { axiosInstance } from "@halo-dev/api-client"
import { VPageHeader, VButton, VCard, Toast } from "@halo-dev/components"
import { IconPlug } from "@halo-dev/components"
@@ -144,13 +325,39 @@ interface PersonaResponse {
avatar: string
}
interface HealthResponse {
status: string
}
const stats = ref<StatsResponse | null>(null)
const persona = ref<PersonaResponse | null>(null)
const range = ref("7")
const health = ref<HealthResponse | null>(null)
const healthVisible = ref(true)
const passRate = computed(() => {
if (!stats.value || stats.value.total === 0) return 0
return Math.round((stats.value.passCount / stats.value.total) * 100)
})
const scoreColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "text-green-600"
if (score >= 4) return "text-amber-500"
return "text-red-500"
})
const scoreBarColor = computed(() => {
const score = stats.value?.avgScore || 0
if (score >= 7) return "bg-gradient-to-r from-green-400 to-green-500"
if (score >= 4) return "bg-gradient-to-r from-amber-400 to-amber-500"
return "bg-gradient-to-r from-red-400 to-red-500"
})
const fetchStats = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats",
`/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/stats?range=${range.value}`,
)
stats.value = data
} catch (e) {
@@ -158,34 +365,72 @@ const fetchStats = async () => {
}
}
const computeGravatarHash = async (email: string): Promise<string> => {
const normalized = email.trim().toLowerCase()
const data = new TextEncoder().encode(normalized)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, "0")).join("")
}
const fetchPersona = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/persona",
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/personas",
)
persona.value = data
const personas = Array.isArray(data) ? data : (data.items || [])
const defaultPersona = personas.find((p: any) => p.spec?.isDefault) || personas[0]
if (defaultPersona) {
persona.value = {
name: defaultPersona.spec?.displayName || '未命名',
prompt: defaultPersona.spec?.prompt || '',
avatar: '', // 需要单独计算
}
// 计算 Gravatar
const email = defaultPersona.spec?.email
if (email) {
const hash = await computeGravatarHash(email)
persona.value.avatar = `https://cn.cravatar.com/avatar/${hash}`
}
}
} catch (e) {
console.error("Failed to fetch persona", e)
}
}
const fetchHealth = async () => {
try {
const { data } = await axiosInstance.get(
"/apis/console.api.comment-ai-autopilot.nxxy335.top/v1alpha1/health",
)
health.value = data
} catch (e) {
console.error("Failed to fetch health", e)
}
}
const refreshData = () => {
fetchStats()
fetchPersona()
Toast.success("数据已刷新")
}
const openSettings = () => {
window.location.href = "/console/comment-ai-autopilot/settings"
}
const getSentimentBarHeight = (sentiment: string): number => {
const getSentimentPercent = (sentiment: string): number => {
const dist = stats.value?.sentimentDistribution
if (!dist) return 0
const max = Math.max(dist.POSITIVE || 0, dist.NEUTRAL || 0, dist.NEGATIVE || 0, 1)
const value = dist[sentiment] || 0
return Math.max((value / max) * 80, value > 0 ? 8 : 0)
const total = Object.values(dist).reduce((a, b) => a + b, 0)
if (total === 0) return 0
return Math.round(((dist[sentiment] || 0) / total) * 100)
}
const getTrendBarHeight = (count: number): number => {
const trend = stats.value?.dailyTrend
if (!trend || trend.length === 0) return 0
const max = Math.max(...trend.map(d => d.count), 1)
return Math.max((count / max) * 80, count > 0 ? 8 : 0)
return Math.max((count / max) * 100, count > 0 ? 8 : 4)
}
const formatTrendDate = (dateStr: string): string => {
@@ -194,8 +439,13 @@ const formatTrendDate = (dateStr: string): string => {
return parts.length >= 3 ? `${parts[1]}/${parts[2]}` : dateStr
}
watch(range, () => {
fetchStats()
})
onMounted(() => {
fetchStats()
fetchPersona()
fetchHealth()
})
</script>
+7 -1
View File
@@ -141,7 +141,7 @@
</div>
<!-- Row 2: AI reply content (truncated) -->
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3">
<div class="text-sm text-gray-800 leading-relaxed break-words line-clamp-3 mt-2">
{{ stripHtml(reply.spec.reply) || '(空)' }}
</div>
</div>
@@ -628,6 +628,12 @@ onMounted(fetchReplies)
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
File diff suppressed because it is too large Load Diff