first commit
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package top.nxxy335.commentaiautopilot;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.index.IndexSpecs;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
/**
|
||||
* <p>Plugin main class to manage the lifecycle of the plugin.</p>
|
||||
* <p>This class must be public and have a public constructor.</p>
|
||||
* <p>Only one main class extending {@link BasePlugin} is allowed per plugin.</p>
|
||||
*
|
||||
* @author 暖心向阳335
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Component
|
||||
public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public CommentAiAutopilotPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
|
||||
super(pluginContext);
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(AiCommentReply.class, indexSpecs -> {
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.commentId", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getCommentId()));
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.postId", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getPostId()));
|
||||
indexSpecs.add(IndexSpecs.<AiCommentReply, String>single("spec.status", String.class)
|
||||
.indexFunc(ext -> ext.getSpec().getStatus()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(AiCommentReply.class));
|
||||
}
|
||||
}
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
package top.nxxy335.commentaiautopilot.endpoint;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
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.Reply;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
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.service.AiReplyOrchestrator;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
|
||||
public CommentAiAutopilotEndpoint(ReactiveExtensionClient client, AiReplyOrchestrator orchestrator) {
|
||||
this.client = client;
|
||||
this.orchestrator = orchestrator;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return route()
|
||||
.GET("/replies", this::listReplies)
|
||||
.POST("/replies/batch-approve", this::batchApproveReplies)
|
||||
.POST("/replies/batch-reject", this::batchRejectReplies)
|
||||
.POST("/replies/batch-delete", this::batchDeleteReplies)
|
||||
.DELETE("/replies/{name}", this::deleteReply)
|
||||
.GET("/stats", this::getStats)
|
||||
.GET("/persona", this::getPersona)
|
||||
.GET("/conversation/{commentName}", this::getConversation)
|
||||
.POST("/replies/{name}/approve", this::approveReply)
|
||||
.POST("/replies/{name}/reject", this::rejectReply)
|
||||
.POST("/comments/{commentName}/trigger", this::triggerReply)
|
||||
.POST("/replies/{replyName}/trigger-conversation", this::triggerConversationReply)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return new GroupVersion("console.api.comment-ai-autopilot.nxxy335.top", "v1alpha1");
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listReplies(ServerRequest request) {
|
||||
var page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||
var size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||
var sort = Sort.by(Sort.Order.desc("metadata.creationTimestamp"));
|
||||
var pageable = PageRequestImpl.of(page, size, sort);
|
||||
|
||||
return client.listBy(AiCommentReply.class, ListOptions.builder().build(), pageable)
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> client.delete(record))
|
||||
.then(ServerResponse.ok().bodyValue("{\"message\":\"deleted\"}"))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getStats(ServerRequest request) {
|
||||
return client.listAll(AiCommentReply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.collectList()
|
||||
.map(replies -> {
|
||||
long total = replies.size();
|
||||
long passCount = replies.stream()
|
||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())).count();
|
||||
long failCount = replies.stream()
|
||||
.filter(r -> "FAIL".equals(r.getSpec().getStatus())).count();
|
||||
double avgScore = replies.stream()
|
||||
.filter(r -> r.getSpec().getScore() != null && r.getSpec().getScore() > 0)
|
||||
.mapToInt(r -> r.getSpec().getScore())
|
||||
.average().orElse(0.0);
|
||||
|
||||
long reviewingCount = replies.stream()
|
||||
.filter(r -> "PASS".equals(r.getSpec().getStatus())
|
||||
&& !Boolean.TRUE.equals(r.getSpec().getPublished()))
|
||||
.count();
|
||||
|
||||
Map<String, Long> sentimentDistribution = new HashMap<>();
|
||||
sentimentDistribution.put("POSITIVE", 0L);
|
||||
sentimentDistribution.put("NEUTRAL", 0L);
|
||||
sentimentDistribution.put("NEGATIVE", 0L);
|
||||
sentimentDistribution.put("UNKNOWN", 0L);
|
||||
for (var r : replies) {
|
||||
String sentiment = r.getSpec().getSentiment();
|
||||
if (sentiment == null || sentiment.isBlank()) {
|
||||
sentimentDistribution.merge("UNKNOWN", 1L, Long::sum);
|
||||
} else {
|
||||
sentimentDistribution.merge(sentiment, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
ZoneId zoneId = ZoneId.systemDefault();
|
||||
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++) {
|
||||
dailyMap.put(today.minusDays(i), 0L);
|
||||
}
|
||||
for (var r : replies) {
|
||||
Instant timestamp = r.getMetadata().getCreationTimestamp();
|
||||
if (timestamp != null) {
|
||||
try {
|
||||
LocalDate date = timestamp.atZone(zoneId).toLocalDate();
|
||||
if (dailyMap.containsKey(date)) {
|
||||
dailyMap.merge(date, 1L, Long::sum);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
List<DailyCount> dailyTrend = new ArrayList<>();
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate date = today.minusDays(i);
|
||||
dailyTrend.add(new DailyCount(date.format(formatter), dailyMap.get(date)));
|
||||
}
|
||||
|
||||
return new StatsResponse(total, passCount, failCount, avgScore,
|
||||
reviewingCount, sentimentDistribution, dailyTrend);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Failed to fetch stats: {}", e.getMessage());
|
||||
return Mono.just(new StatsResponse(0, 0, 0, 0.0, 0L,
|
||||
Map.of("POSITIVE", 0L, "NEUTRAL", 0L, "NEGATIVE", 0L, "UNKNOWN", 0L),
|
||||
List.of()));
|
||||
})
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats));
|
||||
}
|
||||
|
||||
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("小回", "", "");
|
||||
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("小回", "", "");
|
||||
}
|
||||
})
|
||||
.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));
|
||||
}
|
||||
|
||||
public record DailyCount(String date, long count) {}
|
||||
|
||||
public record StatsResponse(
|
||||
long total,
|
||||
long passCount,
|
||||
long failCount,
|
||||
double avgScore,
|
||||
long reviewingCount,
|
||||
Map<String, Long> sentimentDistribution,
|
||||
List<DailyCount> dailyTrend
|
||||
) {}
|
||||
|
||||
public record PersonaResponse(
|
||||
String name,
|
||||
String prompt,
|
||||
String avatar
|
||||
) {}
|
||||
|
||||
private Mono<ServerResponse> getConversation(ServerRequest request) {
|
||||
var commentName = request.pathVariable("commentName");
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
var commentOwner = extractOwnerName(comment.getSpec().getOwner());
|
||||
var commentContent = extractContent(comment.getSpec().getRaw(), comment.getSpec().getContent());
|
||||
var commentTime = String.valueOf(comment.getMetadata().getCreationTimestamp());
|
||||
var isCommentAi = isAiOwner(comment.getSpec().getOwner());
|
||||
|
||||
var commentMsg = new ConversationMessage(
|
||||
"comment", commentOwner, commentContent, commentTime, isCommentAi
|
||||
);
|
||||
|
||||
return client.listAll(Reply.class, ListOptions.builder().build(), Sort.unsorted())
|
||||
.filter(reply -> commentName.equals(reply.getSpec().getCommentName()))
|
||||
.sort(Comparator.comparing(r -> r.getMetadata().getCreationTimestamp()))
|
||||
.map(reply -> {
|
||||
var replyOwner = extractOwnerName(reply.getSpec().getOwner());
|
||||
var replyContent = extractContent(reply.getSpec().getRaw(), reply.getSpec().getContent());
|
||||
var replyTime = String.valueOf(reply.getMetadata().getCreationTimestamp());
|
||||
var isAi = isAiOwner(reply.getSpec().getOwner());
|
||||
return new ConversationMessage("reply", replyOwner, replyContent, replyTime, isAi);
|
||||
})
|
||||
.collectList()
|
||||
.map(replyList -> {
|
||||
List<ConversationMessage> messages = new ArrayList<>();
|
||||
messages.add(commentMsg);
|
||||
messages.addAll(replyList);
|
||||
return messages;
|
||||
});
|
||||
})
|
||||
.flatMap(messages -> ServerResponse.ok().bodyValue(Map.of("messages", messages)))
|
||||
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of("messages", List.of())));
|
||||
}
|
||||
|
||||
private String extractOwnerName(Comment.CommentOwner owner) {
|
||||
if (owner == null) return "匿名用户";
|
||||
var displayName = owner.getDisplayName();
|
||||
return (displayName != null && !displayName.isBlank()) ? displayName : "匿名用户";
|
||||
}
|
||||
|
||||
private String extractContent(String raw, String content) {
|
||||
if (raw != null && !raw.isBlank()) return raw;
|
||||
if (content != null && !content.isBlank()) return content;
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean isAiOwner(Comment.CommentOwner owner) {
|
||||
if (owner == null) return false;
|
||||
var annotations = owner.getAnnotations();
|
||||
if (annotations != null) {
|
||||
return "true".equals(annotations.get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> approveReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Find the corresponding Reply and set approved=true
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "approved")));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> rejectReply(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> {
|
||||
// Delete the draft Reply if it exists
|
||||
return findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> {
|
||||
// Update AiCommentReply record status to REJECTED
|
||||
return client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
});
|
||||
}))
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "rejected")));
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchApproveReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> {
|
||||
reply.getSpec().setApproved(true);
|
||||
reply.getSpec().setApprovedTime(Instant.now());
|
||||
return client.update(reply);
|
||||
})
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setPublished(true);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch approve failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchRejectReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> findReplyForRecord(record)
|
||||
.flatMap(reply -> client.delete(reply))
|
||||
.then(Mono.defer(() -> client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setStatus("REJECTED");
|
||||
latest.getSpec().setPublished(false);
|
||||
return client.update(latest);
|
||||
})))
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch reject failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> batchDeleteReplies(ServerRequest request) {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(body -> {
|
||||
List<String> names;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
names = new ArrayList<>();
|
||||
node.get("names").forEach(n -> names.add(n.asText()));
|
||||
} catch (Exception e) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("successCount", 0, "failCount", 0));
|
||||
}
|
||||
return Flux.fromIterable(names)
|
||||
.flatMap(name ->
|
||||
client.fetch(AiCommentReply.class, name)
|
||||
.flatMap(record -> client.delete(record)
|
||||
.thenReturn(true)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Batch delete failed for {}: {}", name, e.getMessage());
|
||||
return Mono.just(false);
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
)
|
||||
.collectList()
|
||||
.flatMap(results -> {
|
||||
long successCount = results.stream().filter(b -> b).count();
|
||||
long failCount = results.size() - successCount;
|
||||
return ServerResponse.ok()
|
||||
.bodyValue(Map.of("successCount", successCount, "failCount", failCount));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> triggerReply(ServerRequest request) {
|
||||
var commentName = request.pathVariable("commentName");
|
||||
|
||||
// Check if there's already an AiCommentReply record for this comment
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> commentName.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.flatMap(hasExisting -> {
|
||||
if (hasExisting) {
|
||||
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回复")));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> triggerConversationReply(ServerRequest request) {
|
||||
var replyName = request.pathVariable("replyName");
|
||||
|
||||
// First fetch the reply to get its parent comment name
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> {
|
||||
var commentName = reply.getSpec().getCommentName();
|
||||
|
||||
// Check if there's already an AiCommentReply record for this conversation
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> replyName.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.flatMap(hasExisting -> {
|
||||
if (hasExisting) {
|
||||
return ServerResponse.badRequest()
|
||||
.bodyValue(Map.of("message", "该回复已有AI对话记录"));
|
||||
}
|
||||
return orchestrator.processComment(commentName, replyName, true)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")));
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
private Mono<Reply> findReplyForRecord(AiCommentReply record) {
|
||||
// Find the Reply that belongs to the same comment and was created by AI
|
||||
return client.list(Reply.class,
|
||||
reply -> {
|
||||
if (!record.getSpec().getCommentId().equals(reply.getSpec().getCommentName())) {
|
||||
return false;
|
||||
}
|
||||
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"));
|
||||
},
|
||||
null)
|
||||
.next()
|
||||
.switchIfEmpty(Mono.empty());
|
||||
}
|
||||
|
||||
public record ConversationMessage(
|
||||
String type,
|
||||
String owner,
|
||||
String content,
|
||||
String time,
|
||||
boolean isAi
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package top.nxxy335.commentaiautopilot.extension;
|
||||
|
||||
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 = "AiCommentReply",
|
||||
plural = "aicommentreplies",
|
||||
singular = "aicommentreply"
|
||||
)
|
||||
public class AiCommentReply extends AbstractExtension {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
@Schema(name = "AiCommentReplySpec")
|
||||
public static class Spec {
|
||||
|
||||
@Schema(description = "关联评论ID")
|
||||
private String commentId;
|
||||
|
||||
@Schema(description = "关联文章ID")
|
||||
private String postId;
|
||||
|
||||
@Schema(description = "关联文章Slug,用于生成文章链接")
|
||||
private String postSlug;
|
||||
|
||||
@Schema(description = "AI回复内容")
|
||||
private String reply;
|
||||
|
||||
@Schema(description = "审核评分")
|
||||
private Integer score;
|
||||
|
||||
@Schema(description = "状态: PENDING/REVIEWING/PASS/FAIL")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "重试次数")
|
||||
private Integer retryCount;
|
||||
|
||||
@Schema(description = "回复目标的评论ID")
|
||||
private String replyTo;
|
||||
|
||||
@Schema(description = "是否为AI对话中的回复")
|
||||
private Boolean isAiConversation;
|
||||
|
||||
@Schema(description = "是否已发布回复")
|
||||
private Boolean published;
|
||||
|
||||
@Schema(description = "评论情感倾向: POSITIVE/NEUTRAL/NEGATIVE")
|
||||
private String sentiment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package top.nxxy335.commentaiautopilot.listener;
|
||||
|
||||
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.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
|
||||
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-";
|
||||
|
||||
// Record the time when this bean was created (plugin startup time)
|
||||
private final Instant pluginStartTime = Instant.now();
|
||||
|
||||
// In-memory dedup lock: prevents the same comment from being processed multiple times
|
||||
// even if reconcile is triggered concurrently
|
||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
var name = request.name();
|
||||
|
||||
// Acquire lock at the very beginning to prevent any concurrent processing
|
||||
if (processingLocks.putIfAbsent(name, Boolean.TRUE) != null) {
|
||||
log.debug("[CommentReconciler] Already processing comment: {}, skipping", name);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
AtomicBoolean asyncStarted = new AtomicBoolean(false);
|
||||
try {
|
||||
client.fetch(Comment.class, name).ifPresent(comment -> {
|
||||
if (isProcessed(comment.getMetadata().getAnnotations())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comments created before plugin startup (historical comments)
|
||||
var creationTime = comment.getMetadata().getCreationTimestamp();
|
||||
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
||||
log.debug("[CommentReconciler] Skipping historical comment: {} (created before plugin startup)", name);
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comments from AI persona itself
|
||||
if (isAiComment(comment)) {
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup: check if we already have an AiCommentReply record for this comment
|
||||
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
||||
record -> name.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.isEmpty();
|
||||
|
||||
if (alreadyHasRecord) {
|
||||
log.debug("[CommentReconciler] Already have AiCommentReply record for: {}, skipping", name);
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed first to avoid re-processing
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}", name);
|
||||
asyncStarted.set(true);
|
||||
orchestrator.processComment(name, null, false)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.doFinally(signal -> {
|
||||
processingLocks.remove(name);
|
||||
log.debug("[CommentReconciler] Released processing lock for: {}", name);
|
||||
})
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||
);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[CommentReconciler] Error in reconcile for {}: {}", name, e.getMessage(), e);
|
||||
} finally {
|
||||
// Only release lock here if async processing was NOT started
|
||||
// (async path releases lock in doFinally)
|
||||
if (!asyncStarted.get()) {
|
||||
processingLocks.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment is from AI persona.
|
||||
*/
|
||||
private boolean isAiComment(Comment comment) {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isProcessed(Map<String, String> annotations) {
|
||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||
}
|
||||
|
||||
private void markProcessed(Comment comment) {
|
||||
var annotations = comment.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
comment.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Comment())
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package top.nxxy335.commentaiautopilot.listener;
|
||||
|
||||
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.Reply;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
|
||||
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";
|
||||
|
||||
// Record the time when this bean was created (plugin startup time)
|
||||
private final Instant pluginStartTime = Instant.now();
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
var name = request.name();
|
||||
|
||||
client.fetch(Reply.class, name).ifPresent(reply -> {
|
||||
if (isProcessed(reply.getMetadata().getAnnotations())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip replies created before plugin startup (historical replies)
|
||||
var creationTime = reply.getMetadata().getCreationTimestamp();
|
||||
if (creationTime != null && creationTime.isBefore(pluginStartTime)) {
|
||||
log.debug("[ReplyReconciler] Skipping historical reply: {} (created before plugin startup)", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip replies from AI persona itself
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
// Also skip if owner has AI marker annotation
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
String parentCommentName = reply.getSpec().getCommentName();
|
||||
if (parentCommentName == null || parentCommentName.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this reply is specifically replying to an AI reply
|
||||
// by checking the quoteReply field
|
||||
String quoteReply = reply.getSpec().getQuoteReply();
|
||||
|
||||
if (quoteReply == null || quoteReply.isBlank()) {
|
||||
// No quoteReply - this is a direct reply to the top-level comment,
|
||||
// NOT a reply to AI. Skip it (CommentReconciler handles top-level comments).
|
||||
log.debug("[ReplyReconciler] Reply {} has no quoteReply, skipping (not a reply to AI)", name);
|
||||
return;
|
||||
}
|
||||
|
||||
// This reply quotes another reply - check if the quoted reply is from AI
|
||||
boolean isReplyToAi = isAiReply(quoteReply);
|
||||
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
|
||||
|
||||
if (!isReplyToAi) {
|
||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup: check if we already have an AiCommentReply record for this reply
|
||||
boolean alreadyHasRecord = !client.list(AiCommentReply.class,
|
||||
record -> name.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.isEmpty();
|
||||
|
||||
if (alreadyHasRecord) {
|
||||
log.debug("[ReplyReconciler] Already have AiCommentReply record for reply: {}, skipping", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed
|
||||
markProcessed(reply);
|
||||
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)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
|
||||
);
|
||||
});
|
||||
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific Reply is from AI persona.
|
||||
*/
|
||||
private boolean isAiReply(String replyName) {
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.map(reply -> {
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null && owner.getName() != null
|
||||
&& owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
if (owner != null && owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get(AI_MARKER_ANNOTATION))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private boolean isProcessed(Map<String, String> annotations) {
|
||||
return annotations != null && "true".equals(annotations.get(PROCESSED_ANNOTATION));
|
||||
}
|
||||
|
||||
private void markProcessed(Reply reply) {
|
||||
var annotations = reply.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
reply.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new Reply())
|
||||
.syncAllOnStart(false)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.nxxy335.commentaiautopilot.processor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.thymeleaf.context.ITemplateContext;
|
||||
import org.thymeleaf.model.IModel;
|
||||
import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||
|
||||
/**
|
||||
* Template head processor for comment-ai-autopilot.
|
||||
* Previously used to inject avatar replacement scripts, but now
|
||||
* avatar is handled natively via Gravatar (email-based).
|
||||
* Kept as a no-op to avoid breaking the Spring component scan.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiBadgeHeadProcessor implements TemplateHeadProcessor {
|
||||
|
||||
@Override
|
||||
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||
IElementModelStructureHandler structureHandler) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.aifoundation.AiModelService;
|
||||
import run.halo.aifoundation.chat.LanguageModel;
|
||||
import run.halo.aifoundation.chat.GenerateTextResult;
|
||||
|
||||
/**
|
||||
* AI Foundation client that calls the AI Foundation plugin's AiModelService.
|
||||
* Only instantiated when AI Foundation classes are available (via @ConditionalOnClass).
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiFoundationClient {
|
||||
|
||||
private static final String AI_FOUNDATION_PLUGIN_NAME = "ai-foundation";
|
||||
|
||||
private final ExtensionGetter extensionGetter;
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Call AI Foundation to generate a chat response using the specified model.
|
||||
* Checks at runtime whether the ai-foundation plugin is installed and enabled
|
||||
* before attempting to use it.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the AiModel metadata.name, null or blank to use default model
|
||||
* @return the generated text, or empty if AI Foundation is unavailable
|
||||
*/
|
||||
public Mono<String> chat(String prompt, String modelName) {
|
||||
return isAiFoundationEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) {
|
||||
log.warn("AI Foundation plugin is not installed or not enabled, skipping AI reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return doChat(prompt, modelName);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the ai-foundation plugin is installed and enabled at runtime.
|
||||
*/
|
||||
private Mono<Boolean> isAiFoundationEnabled() {
|
||||
return client.fetch(Plugin.class, AI_FOUNDATION_PLUGIN_NAME)
|
||||
.map(plugin -> plugin.getSpec().getEnabled())
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to check AI Foundation plugin status: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> doChat(String prompt, String modelName) {
|
||||
return extensionGetter.getEnabledExtension(AiModelService.class)
|
||||
.flatMap(service -> {
|
||||
Mono<LanguageModel> modelMono;
|
||||
if (modelName != null && !modelName.isBlank()) {
|
||||
modelMono = service.languageModel(modelName);
|
||||
} else {
|
||||
modelMono = service.languageModel();
|
||||
}
|
||||
return modelMono.flatMap(model -> model.generateText(prompt)
|
||||
.map(GenerateTextResult::getText)
|
||||
.doOnNext(text -> log.debug("AI generated reply ({} chars) using model '{}'",
|
||||
text.length(), modelName != null ? modelName : "default"))
|
||||
);
|
||||
})
|
||||
.doOnError(e -> log.error("AI Foundation call failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
|
||||
/**
|
||||
* Configuration that registers AiFoundationClient only when
|
||||
* AI Foundation plugin classes are available in the classloader.
|
||||
* When AI Foundation is not installed, this entire configuration is skipped.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "run.halo.aifoundation.AiModelService")
|
||||
public class AiFoundationConfiguration {
|
||||
|
||||
@Bean
|
||||
public AiFoundationClient aiFoundationClient(ExtensionGetter extensionGetter,
|
||||
ReactiveExtensionClient client) {
|
||||
return new AiFoundationClient(extensionGetter, client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiReplyOrchestrator {
|
||||
|
||||
private final ContextExtractor contextExtractor;
|
||||
private final PromptBuilder promptBuilder;
|
||||
private final AiReplyService aiReplyService;
|
||||
private final SentimentService sentimentService;
|
||||
private final ReviewService reviewService;
|
||||
private final CommentReplyPublisher commentReplyPublisher;
|
||||
private final FilterService filterService;
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
// In-memory dedup: tracks which comment/reply is currently being processed
|
||||
// Prevents duplicate replies when Reconciler fires multiple times
|
||||
private final ConcurrentHashMap<String, Boolean> processingLocks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Process a new comment or reply.
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation) {
|
||||
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
||||
|
||||
// In-memory dedup: if already processing, skip immediately
|
||||
if (processingLocks.putIfAbsent(lockKey, Boolean.TRUE) != null) {
|
||||
log.info("[Orchestrator] Already processing: {}, skipping duplicate", lockKey);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}",
|
||||
commentName, replyName, isAiConversation);
|
||||
|
||||
return isAutoReplyEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) {
|
||||
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 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) {
|
||||
return hasExistingReply(commentName)
|
||||
.flatMap(hasReply -> {
|
||||
if (hasReply) {
|
||||
log.info("[Orchestrator] Already have reply record for: {}, skipping", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doProcess(commentName, replyName, isAiConversation);
|
||||
});
|
||||
}
|
||||
return hasExistingConversationReply(replyName)
|
||||
.flatMap(hasReply -> {
|
||||
if (hasReply) {
|
||||
log.info("[Orchestrator] Already replied to reply: {}, skipping", replyName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doProcess(commentName, replyName, isAiConversation);
|
||||
});
|
||||
});
|
||||
})
|
||||
.doOnError(e -> log.error("[Orchestrator] Error processing comment {}: {}", commentName, e.getMessage(), e))
|
||||
.doFinally(signal -> {
|
||||
// Always release the lock when processing completes
|
||||
processingLocks.remove(lockKey);
|
||||
log.debug("[Orchestrator] Released processing lock for: {}", lockKey);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation) {
|
||||
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))
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's already ANY AiCommentReply record for this top-level comment.
|
||||
* Checks for ANY record (not just published) to prevent race conditions.
|
||||
*/
|
||||
private Mono<Boolean> hasExistingReply(String commentName) {
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> commentName.equals(record.getSpec().getCommentId())
|
||||
&& !Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to check existing replies: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we already have ANY AiCommentReply record for this specific reply (conversation).
|
||||
* Checks for ANY record (not just published) to prevent race conditions.
|
||||
*/
|
||||
private Mono<Boolean> hasExistingConversationReply(String replyName) {
|
||||
if (replyName == null || replyName.isBlank()) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return client.list(AiCommentReply.class,
|
||||
record -> replyName.equals(record.getSpec().getReplyTo())
|
||||
&& Boolean.TRUE.equals(record.getSpec().getIsAiConversation()),
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to check existing conversation replies: {}", e.getMessage());
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI reply, optionally review it, then publish.
|
||||
*/
|
||||
private Mono<Void> generateAndPublish(String prompt, ContextExtractor.CommentContext context,
|
||||
AiCommentReply replyRecord, String modelName) {
|
||||
return aiReplyService.generateReply(prompt, modelName)
|
||||
.defaultIfEmpty("")
|
||||
.flatMap(aiReply -> {
|
||||
if (aiReply.isBlank()) {
|
||||
log.warn("[Orchestrator] AI generated empty reply for: {}", context.commentId());
|
||||
return updateRecord(replyRecord, "", 0, "FAIL", false).then();
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] AI generated reply for {}: {} chars",
|
||||
context.commentId(), aiReply.length());
|
||||
|
||||
return reviewService.review(context.postContent(), context.commentContent(), aiReply, modelName)
|
||||
.flatMap(reviewResult -> {
|
||||
log.info("[Orchestrator] Review for {}: score={}, status={}, reason={}",
|
||||
context.commentId(), reviewResult.score(), reviewResult.status(), reviewResult.reason());
|
||||
if ("FAIL".equals(reviewResult.status())) {
|
||||
log.warn("[Orchestrator] Content safety review FAILED for: {}, not publishing",
|
||||
context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, 0, "FAIL", false).then();
|
||||
}
|
||||
return publishReply(context, aiReply, replyRecord, reviewResult.score());
|
||||
})
|
||||
.switchIfEmpty(
|
||||
publishReply(context, aiReply, replyRecord, 100)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Orchestrator] Review error, auto-passing: {}", e.getMessage());
|
||||
return publishReply(context, aiReply, replyRecord, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the reply and update the record to PASS + published=true.
|
||||
*/
|
||||
private Mono<Void> publishReply(ContextExtractor.CommentContext context, String aiReply,
|
||||
AiCommentReply replyRecord, int score) {
|
||||
return isAutoPublishEnabled()
|
||||
.flatMap(autoPublish -> {
|
||||
return commentReplyPublisher.publishReply(
|
||||
context.commentId(), aiReply, context.postId(), context.replyTo(), autoPublish)
|
||||
.flatMap(publishedReply -> {
|
||||
log.info("[Orchestrator] Reply {} for: {}", autoPublish ? "published" : "saved as draft", context.commentId());
|
||||
return updateRecord(replyRecord, aiReply, score, "PASS", autoPublish);
|
||||
})
|
||||
.flatMap(updated -> Mono.empty());
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<String> getModelName() {
|
||||
return settingFetcher.getSettingValue("model")
|
||||
.map(node -> {
|
||||
var nameNode = node.get("modelName");
|
||||
if (nameNode != null && !nameNode.asText().isBlank()) {
|
||||
return nameNode.asText();
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch model setting: {}", e.getMessage());
|
||||
return Mono.just("");
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoReplyEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoReply") || node.get("autoReply").asBoolean(true))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoReply setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<Boolean> isAutoPublishEnabled() {
|
||||
return settingFetcher.getSettingValue("basic")
|
||||
.map(node -> !node.has("autoPublish") || node.get("autoPublish").asBoolean(true))
|
||||
.onErrorResume(e -> {
|
||||
log.debug("[Orchestrator] Failed to fetch autoPublish setting: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
})
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> createAiCommentReply(ContextExtractor.CommentContext context, String sentiment) {
|
||||
AiCommentReply record = new AiCommentReply();
|
||||
record.setMetadata(new Metadata());
|
||||
record.getMetadata().setName("ai-reply-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
record.setSpec(new AiCommentReply.Spec());
|
||||
record.getSpec().setCommentId(context.commentId());
|
||||
record.getSpec().setPostId(context.postId());
|
||||
record.getSpec().setPostSlug(context.postSlug());
|
||||
record.getSpec().setReply("");
|
||||
record.getSpec().setScore(0);
|
||||
record.getSpec().setStatus("PENDING");
|
||||
record.getSpec().setRetryCount(0);
|
||||
record.getSpec().setReplyTo(context.replyTo());
|
||||
record.getSpec().setIsAiConversation(context.isAiConversation());
|
||||
record.getSpec().setPublished(false);
|
||||
record.getSpec().setSentiment(sentiment);
|
||||
return client.create(record)
|
||||
.doOnSuccess(created -> log.info("[Orchestrator] Created AiCommentReply record: {}",
|
||||
created.getMetadata().getName()));
|
||||
}
|
||||
|
||||
private Mono<AiCommentReply> updateRecord(AiCommentReply record, String reply,
|
||||
int score, String status, boolean published) {
|
||||
log.debug("[Orchestrator] Updating record {}: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published);
|
||||
return client.fetch(AiCommentReply.class, record.getMetadata().getName())
|
||||
.flatMap(latest -> {
|
||||
latest.getSpec().setReply(reply);
|
||||
latest.getSpec().setScore(score);
|
||||
latest.getSpec().setStatus(status);
|
||||
latest.getSpec().setPublished(published);
|
||||
return client.update(latest);
|
||||
})
|
||||
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
|
||||
.filter(e -> e instanceof OptimisticLockingFailureException)
|
||||
.doBeforeRetry(signal -> log.debug("[Orchestrator] Retrying update for {} due to optimistic lock",
|
||||
record.getMetadata().getName()))
|
||||
)
|
||||
.doOnSuccess(updated -> log.debug("[Orchestrator] Record {} updated: status={}, score={}, published={}",
|
||||
record.getMetadata().getName(), status, score, published));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class AiReplyService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
|
||||
public AiReplyService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an AI reply using the AI Foundation plugin.
|
||||
*
|
||||
* @param prompt the prompt text
|
||||
* @param modelName the model name (null for default)
|
||||
*/
|
||||
public Mono<String> generateReply(String prompt, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, cannot generate reply");
|
||||
return Mono.empty();
|
||||
}
|
||||
return client.chat(prompt, modelName)
|
||||
.doOnError(e -> log.error("AI reply generation failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("AI Foundation not available: {}", e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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 java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
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.
|
||||
*/
|
||||
public Mono<Reply> publishReply(String parentCommentName, String replyContent,
|
||||
String postName, String quoteReplyName, boolean autoPublish) {
|
||||
return checkExistingAiReply(parentCommentName, quoteReplyName)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
log.info("[Publisher] AI reply already exists for comment: {}, skipping duplicate publish",
|
||||
parentCommentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return doPublish(parentCommentName, replyContent, postName, quoteReplyName, autoPublish);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkExistingAiReply(String parentCommentName, String quoteReplyName) {
|
||||
return client.list(Reply.class,
|
||||
reply -> {
|
||||
if (!parentCommentName.equals(reply.getSpec().getCommentName())) {
|
||||
return false;
|
||||
}
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner == null || owner.getName() == null) {
|
||||
return false;
|
||||
}
|
||||
boolean isAiOwner = owner.getName().startsWith(AI_PERSONA_OWNER_PREFIX);
|
||||
boolean hasAiAnnotation = owner.getAnnotations() != null
|
||||
&& "true".equals(owner.getAnnotations().get("comment-ai-autopilot.nxxy335.top/is-ai"));
|
||||
boolean isAiReply = isAiOwner || hasAiAnnotation;
|
||||
|
||||
if (!isAiReply) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
return quoteReplyName.equals(reply.getSpec().getQuoteReply());
|
||||
}
|
||||
return true;
|
||||
},
|
||||
null)
|
||||
.hasElements()
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Reply> 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());
|
||||
|
||||
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);
|
||||
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
spec.setQuoteReply(quoteReplyName);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
Map<String, String> 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: {}",
|
||||
personaName, 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.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Publisher] Failed to parse personaName from ConfigMap: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 String generateReplyName() {
|
||||
return "ai-comment-reply-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Gravatar URL from email address using SHA-256 hash.
|
||||
*/
|
||||
private String generateGravatarUrl(String email) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
var hashBytes = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
var hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
return "https://cn.cravatar.com/avatar/" + hexString;
|
||||
} catch (Exception e) {
|
||||
log.error("[Publisher] Failed to generate Gravatar URL: {}", e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.content.ContentWrapper;
|
||||
import run.halo.app.content.PostContentService;
|
||||
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.ReactiveExtensionClient;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ContextExtractor {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PostContentService postContentService;
|
||||
|
||||
/**
|
||||
* Extract context from a comment event.
|
||||
* Returns a CommentContext record with all needed info.
|
||||
*
|
||||
* @param commentName the Comment name (always required)
|
||||
* @param replyName the Reply name that triggered this (null for top-level comments)
|
||||
* @param isAiConversation whether this is a continuation of AI conversation
|
||||
*/
|
||||
public Mono<CommentContext> extract(String commentName, String replyName, boolean isAiConversation) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
if (replyName != null && !replyName.isBlank()) {
|
||||
// This is a reply to a comment - fetch the Reply for content
|
||||
return client.fetch(Reply.class, replyName)
|
||||
.flatMap(reply -> buildContextFromReply(comment, reply, isAiConversation))
|
||||
.switchIfEmpty(buildContext(comment, isAiConversation));
|
||||
}
|
||||
return buildContext(comment, isAiConversation);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<CommentContext> buildContext(Comment comment, boolean isAiConversation) {
|
||||
var commentContent = extractCommentContent(comment);
|
||||
var commentOwner = extractCommentOwner(comment);
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
|
||||
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
||||
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
|
||||
))
|
||||
)
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
"",
|
||||
commentContent,
|
||||
commentOwner,
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
return Mono.just(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
"",
|
||||
"",
|
||||
commentContent,
|
||||
commentOwner,
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
private Mono<CommentContext> buildContextFromReply(Comment comment, Reply reply, boolean isAiConversation) {
|
||||
var replyContent = extractReplyContent(reply);
|
||||
var replyOwner = extractReplyOwner(reply);
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
|
||||
if (subjectRef != null && "Post".equals(subjectRef.getKind())) {
|
||||
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
|
||||
))
|
||||
)
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
"",
|
||||
replyContent,
|
||||
replyOwner,
|
||||
"",
|
||||
"",
|
||||
reply.getMetadata().getName(),
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
return Mono.just(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
"",
|
||||
"",
|
||||
replyContent,
|
||||
replyOwner,
|
||||
"",
|
||||
"",
|
||||
reply.getMetadata().getName(),
|
||||
isAiConversation
|
||||
));
|
||||
}
|
||||
|
||||
private String extractCommentContent(Comment comment) {
|
||||
var spec = comment.getSpec();
|
||||
// Prefer raw content (plain text / markdown), fall back to rendered HTML
|
||||
String raw = spec.getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
return raw;
|
||||
}
|
||||
String content = spec.getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return Jsoup.clean(content, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String extractCommentOwner(Comment comment) {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner != null) {
|
||||
String displayName = owner.getDisplayName();
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
return "匿名用户";
|
||||
}
|
||||
|
||||
private String extractReplyContent(Reply reply) {
|
||||
var spec = reply.getSpec();
|
||||
String raw = spec.getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
return raw;
|
||||
}
|
||||
String content = spec.getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return Jsoup.clean(content, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String extractReplyOwner(Reply reply) {
|
||||
var owner = reply.getSpec().getOwner();
|
||||
if (owner != null) {
|
||||
String displayName = owner.getDisplayName();
|
||||
if (displayName != null && !displayName.isBlank()) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
return "匿名用户";
|
||||
}
|
||||
|
||||
private Mono<String> getPostContent(String postName) {
|
||||
return postContentService.getReleaseContent(postName)
|
||||
.map(ContentWrapper::getContent)
|
||||
.map(html -> {
|
||||
if (html != null && !html.isBlank()) {
|
||||
return Jsoup.clean(html, Safelist.none());
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
public record CommentContext(
|
||||
String commentId,
|
||||
String postId,
|
||||
String postSlug,
|
||||
String commentContent,
|
||||
String commentOwner,
|
||||
String postTitle,
|
||||
String postContent,
|
||||
String replyTo,
|
||||
boolean isAiConversation
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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.Post;
|
||||
import run.halo.app.core.extension.content.SinglePage;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FilterService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final String CONFIG_MAP_NAME = "comment-ai-autopilot-configmap";
|
||||
private static final String ANNOTATION_KEY = "comment-ai-autopilot.nxxy335.top/ai-reply-enabled";
|
||||
private static final String GROUP_CONTENT = "content.halo.run";
|
||||
|
||||
public FilterService(ReactiveExtensionClient client) {
|
||||
this.client = client;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public Mono<Boolean> shouldProcess(Comment comment) {
|
||||
return checkBlockedCommenters(comment)
|
||||
.flatMap(blocked -> {
|
||||
if (blocked) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return checkAnnotationEnabled(comment);
|
||||
})
|
||||
.defaultIfEmpty(true)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Filter] Error checking filter rules: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Boolean> shouldProcess(String commentName) {
|
||||
return client.fetch(Comment.class, commentName)
|
||||
.flatMap(this::shouldProcess)
|
||||
.defaultIfEmpty(true)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Filter] Error fetching comment for filter check: {}", e.getMessage());
|
||||
return Mono.just(true);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkBlockedCommenters(Comment comment) {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
var data = cm.getData();
|
||||
if (data == null) return false;
|
||||
String basicJson = data.get("basic");
|
||||
if (basicJson == null || basicJson.isBlank()) return false;
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(basicJson);
|
||||
String blockedCommentersStr = node.has("blockedCommenters")
|
||||
? node.get("blockedCommenters").asText("") : "";
|
||||
List<String> blockedCommenters = parseList(blockedCommentersStr);
|
||||
String commenterName = getCommenterDisplayName(comment);
|
||||
if (isInList(commenterName, blockedCommenters)) {
|
||||
log.info("[Filter] Commenter '{}' is in blocked list, skipping", commenterName);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Filter] Failed to parse basic config: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Boolean> checkAnnotationEnabled(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getSubjectRef() == null) {
|
||||
return Mono.just(true);
|
||||
}
|
||||
var subjectRef = comment.getSpec().getSubjectRef();
|
||||
String group = subjectRef.getGroup();
|
||||
String kind = subjectRef.getKind();
|
||||
String name = subjectRef.getName();
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "Post".equals(kind)) {
|
||||
return client.fetch(Post.class, name)
|
||||
.map(post -> resolveAnnotation(post.getMetadata().getAnnotations(), true))
|
||||
.defaultIfEmpty(true);
|
||||
}
|
||||
|
||||
if (GROUP_CONTENT.equals(group) && "SinglePage".equals(kind)) {
|
||||
return client.fetch(SinglePage.class, name)
|
||||
.map(page -> resolveAnnotation(page.getMetadata().getAnnotations(), false))
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
// Unknown subjectRef type, default to allowing
|
||||
return Mono.just(true);
|
||||
}
|
||||
|
||||
private boolean resolveAnnotation(java.util.Map<String, String> annotations, boolean defaultEnabled) {
|
||||
if (annotations == null || !annotations.containsKey(ANNOTATION_KEY)) {
|
||||
return defaultEnabled;
|
||||
}
|
||||
String value = annotations.get(ANNOTATION_KEY);
|
||||
if ("false".equalsIgnoreCase(value)) {
|
||||
log.info("[Filter] Annotation {} is set to false, skipping", ANNOTATION_KEY);
|
||||
return false;
|
||||
}
|
||||
if ("true".equalsIgnoreCase(value)) {
|
||||
return true;
|
||||
}
|
||||
// Unrecognized value, fall back to default
|
||||
return defaultEnabled;
|
||||
}
|
||||
|
||||
private String getCommenterDisplayName(Comment comment) {
|
||||
if (comment.getSpec() == null || comment.getSpec().getOwner() == null) return "";
|
||||
var displayName = comment.getSpec().getOwner().getDisplayName();
|
||||
return displayName != null ? displayName : "";
|
||||
}
|
||||
|
||||
private List<String> parseList(String str) {
|
||||
if (str == null || str.isBlank()) return Collections.emptyList();
|
||||
return Arrays.stream(str.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class PromptBuilder {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
private static final String SAFETY_PROMPT = """
|
||||
【安全规范】
|
||||
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
|
||||
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。"
|
||||
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
|
||||
""";
|
||||
|
||||
private static final String DEFAULT_PROMPT_TEMPLATE = """
|
||||
{{persona_prompt}}
|
||||
|
||||
{{safety_prompt}}
|
||||
|
||||
【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。
|
||||
|
||||
请回复以下评论。注意:
|
||||
- 回复长度应与评论长度匹配,简短问候简短回复
|
||||
- 不要复述或总结文章内容
|
||||
- 自然对话,不要写小作文
|
||||
- 只有评论涉及具体内容时才针对性回应
|
||||
|
||||
文章(仅供理解上下文,不要复述):
|
||||
{{article}}
|
||||
|
||||
评论:
|
||||
{{comment}}
|
||||
""";
|
||||
|
||||
private static final String DEFAULT_PERSONA_PROMPT = """
|
||||
你是「小回」,一个友善的评论者。你的回复简洁自然,像朋友聊天一样。简短的评论就简短回复,有深度的讨论才展开回应。不要长篇大论,不要复述文章内容。
|
||||
""";
|
||||
|
||||
public Mono<String> buildPrompt(ContextExtractor.CommentContext context) {
|
||||
return Mono.zip(getPromptTemplate(), getPersonaPrompt())
|
||||
.map(tuple -> {
|
||||
String template = tuple.getT1();
|
||||
String personaPrompt = tuple.getT2();
|
||||
|
||||
String prompt = template
|
||||
.replace("{{persona_prompt}}", personaPrompt)
|
||||
.replace("{{safety_prompt}}", SAFETY_PROMPT)
|
||||
.replace("{{article}}", context.postTitle() + "\n" + context.postContent())
|
||||
.replace("{{comment}}", context.commentOwner() + ": " + context.commentContent());
|
||||
|
||||
return prompt;
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<String> buildPrompt(ContextExtractor.CommentContext context, String sentiment) {
|
||||
return buildPrompt(context)
|
||||
.map(prompt -> {
|
||||
if (sentiment == null || "NEUTRAL".equals(sentiment)) {
|
||||
return prompt;
|
||||
}
|
||||
String sentimentHint = switch (sentiment) {
|
||||
case "POSITIVE" -> "\n\n【情感提示】评论者情绪正面积极,请用热情友好的语气回复,可以表达感谢和共鸣。";
|
||||
case "NEGATIVE" -> "\n\n【情感提示】评论者情绪偏负面,请用理性温和的语气回复,避免激化矛盾,展现理解和包容。";
|
||||
default -> "";
|
||||
};
|
||||
return prompt + sentimentHint;
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> getPromptTemplate() {
|
||||
return settingFetcher.getSettingValue("prompt")
|
||||
.map(node -> {
|
||||
var templateNode = node.get("customPromptTemplate");
|
||||
if (templateNode != null && !templateNode.asText().isBlank()) {
|
||||
return templateNode.asText();
|
||||
}
|
||||
return DEFAULT_PROMPT_TEMPLATE;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch prompt template setting: {}", e.getMessage());
|
||||
return Mono.just(DEFAULT_PROMPT_TEMPLATE);
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PROMPT_TEMPLATE);
|
||||
}
|
||||
|
||||
private Mono<String> getPersonaPrompt() {
|
||||
return settingFetcher.getSettingValue("persona")
|
||||
.map(node -> {
|
||||
var promptNode = node.get("personaPrompt");
|
||||
if (promptNode != null && !promptNode.asText().isBlank()) {
|
||||
return promptNode.asText();
|
||||
}
|
||||
return DEFAULT_PERSONA_PROMPT;
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.debug("Failed to fetch persona prompt setting: {}", e.getMessage());
|
||||
return Mono.just(DEFAULT_PERSONA_PROMPT);
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
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) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
this.settingFetcher = settingFetcher;
|
||||
}
|
||||
|
||||
private static final String REVIEW_PROMPT_TEMPLATE = """
|
||||
请你以内容安全审核员的身份,严格评估你刚刚生成的这段回复:
|
||||
|
||||
文章:
|
||||
%s
|
||||
|
||||
原始评论:
|
||||
%s
|
||||
|
||||
生成的回复:
|
||||
%s
|
||||
|
||||
请检查以下内容:
|
||||
1. 是否包含任何辱骂、仇恨、偏见或煽动性言论?
|
||||
2. 是否包含不适宜公开传播的内容?
|
||||
3. 是否涉及暴力、歧视、人身攻击或违反法律法规的内容?
|
||||
|
||||
请直接回答"安全"或"不安全"。如果"不安全",请重新生成一段符合规范的温和回复。
|
||||
""";
|
||||
|
||||
public Mono<ReviewResult> review(String articleContent, String commentContent, String aiReply,
|
||||
String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("AI Foundation plugin is not installed, skipping review (auto-pass)");
|
||||
return Mono.just(new ReviewResult(100, "PASS", "AI Foundation 未安装,自动通过"));
|
||||
}
|
||||
|
||||
String reviewPrompt = String.format(REVIEW_PROMPT_TEMPLATE,
|
||||
truncate(articleContent, 2000),
|
||||
truncate(commentContent, 500),
|
||||
truncate(aiReply, 500));
|
||||
|
||||
return client.chat(reviewPrompt, modelName)
|
||||
.map(this::parseSafetyResult)
|
||||
.defaultIfEmpty(new ReviewResult(100, "PASS", "审核无响应,自动通过"))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("Review failed, auto-passing: {}", e.getMessage());
|
||||
return Mono.just(new ReviewResult(100, "PASS", "审核服务异常,自动通过"));
|
||||
});
|
||||
}
|
||||
|
||||
private ReviewResult parseSafetyResult(String response) {
|
||||
if (response == null || response.isBlank()) {
|
||||
return new ReviewResult(100, "PASS", "审核无响应,自动通过");
|
||||
}
|
||||
String trimmed = response.trim().toLowerCase();
|
||||
if (trimmed.contains("不安全") || trimmed.contains("unsafe")) {
|
||||
log.warn("AI Review: content is UNSAFE, response: {}", response);
|
||||
return new ReviewResult(0, "FAIL", "内容安全审核不通过");
|
||||
}
|
||||
if (trimmed.contains("安全") || trimmed.contains("safe")) {
|
||||
log.info("AI Review: content is SAFE");
|
||||
return new ReviewResult(100, "PASS", "内容安全审核通过");
|
||||
}
|
||||
// If unclear response, default to pass
|
||||
log.warn("AI Review: unclear response, auto-passing: {}", response);
|
||||
return new ReviewResult(100, "PASS", "审核结果不明确,自动通过");
|
||||
}
|
||||
|
||||
private String truncate(String text, int maxLength) {
|
||||
if (text == null) return "";
|
||||
return text.length() > maxLength ? text.substring(0, maxLength) : text;
|
||||
}
|
||||
|
||||
public record ReviewResult(int score, String status, String reason) {}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SentimentService {
|
||||
|
||||
private final ObjectProvider<AiFoundationClient> aiFoundationClientProvider;
|
||||
|
||||
public SentimentService(ObjectProvider<AiFoundationClient> aiFoundationClientProvider) {
|
||||
this.aiFoundationClientProvider = aiFoundationClientProvider;
|
||||
}
|
||||
|
||||
public record SentimentResult(String sentiment, double confidence) {
|
||||
public static final String POSITIVE = "POSITIVE";
|
||||
public static final String NEUTRAL = "NEUTRAL";
|
||||
public static final String NEGATIVE = "NEGATIVE";
|
||||
}
|
||||
|
||||
public Mono<SentimentResult> analyzeSentiment(String commentContent, String modelName) {
|
||||
AiFoundationClient client = aiFoundationClientProvider.getIfAvailable();
|
||||
if (client == null) {
|
||||
log.warn("[Sentiment] AI Foundation plugin is not installed, defaulting to NEUTRAL");
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
String prompt = buildSentimentPrompt(commentContent);
|
||||
|
||||
return client.chat(prompt, modelName)
|
||||
.map(response -> {
|
||||
String sentiment = parseSentiment(response);
|
||||
return new SentimentResult(sentiment, 1.0);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[Sentiment] Failed to analyze sentiment, defaulting to NEUTRAL: {}", e.getMessage());
|
||||
return Mono.just(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
})
|
||||
.defaultIfEmpty(new SentimentResult(SentimentResult.NEUTRAL, 0.0));
|
||||
}
|
||||
|
||||
private String buildSentimentPrompt(String commentContent) {
|
||||
return "请分析以下评论的情感倾向。只回复一个词:POSITIVE(正面)、NEUTRAL(中性)或 NEGATIVE(负面)。\n\n评论内容:\n" + commentContent;
|
||||
}
|
||||
|
||||
private String parseSentiment(String response) {
|
||||
if (response == null || response.isBlank()) return SentimentResult.NEUTRAL;
|
||||
String upper = response.trim().toUpperCase();
|
||||
if (upper.contains("POSITIVE")) return SentimentResult.POSITIVE;
|
||||
if (upper.contains("NEGATIVE")) return SentimentResult.NEGATIVE;
|
||||
return SentimentResult.NEUTRAL;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,29 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: AnnotationSetting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-post-annotation-setting
|
||||
spec:
|
||||
targetRef:
|
||||
group: content.halo.run
|
||||
kind: Post
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: true
|
||||
help: 开启后,该文章收到评论时将自动触发AI回复
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: AnnotationSetting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-single-page-annotation-setting
|
||||
spec:
|
||||
targetRef:
|
||||
group: content.halo.run
|
||||
kind: SinglePage
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: comment-ai-autopilot.nxxy335.top/ai-reply-enabled
|
||||
label: 启用AI回评
|
||||
value: false
|
||||
help: 开启后,该页面收到评论时将自动触发AI回复
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: comment-ai-autopilot-role-manage
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "Comment AI Autopilot Management"
|
||||
rbac.authorization.halo.run/display-name: "AI回评管理"
|
||||
rbac.authorization.halo.run/ui-permissions: |
|
||||
["plugin:comment-ai-autopilot:manage"]
|
||||
rules:
|
||||
- apiGroups: ["comment-ai-autopilot.nxxy335.top"]
|
||||
resources: ["comment-ai-autopilot/aicommentreplies"]
|
||||
verbs: ["*"]
|
||||
- apiGroups: ["console.api.comment-ai-autopilot.nxxy335.top"]
|
||||
resources: ["*"]
|
||||
verbs: ["*"]
|
||||
@@ -0,0 +1,60 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: comment-ai-autopilot-settings
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $formkit: switch
|
||||
name: autoReply
|
||||
label: 自动回复
|
||||
value: true
|
||||
- $formkit: switch
|
||||
name: autoPublish
|
||||
label: 自动发布
|
||||
value: true
|
||||
- $formkit: number
|
||||
name: maxRetryCount
|
||||
label: 最大重试次数
|
||||
value: 3
|
||||
min: 1
|
||||
max: 10
|
||||
- $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头像服务展示头像
|
||||
value: ""
|
||||
- group: model
|
||||
label: 模型设置
|
||||
formSchema:
|
||||
- $formkit: text
|
||||
name: modelName
|
||||
label: AI模型名称
|
||||
help: 留空使用AI Foundation默认模型,填写AiModel资源名称可指定模型
|
||||
value: ""
|
||||
- group: prompt
|
||||
label: Prompt设置
|
||||
formSchema:
|
||||
- $formkit: textarea
|
||||
name: customPromptTemplate
|
||||
label: 自定义Prompt模板
|
||||
value: "{{persona_prompt}}\n\n{{safety_prompt}}\n\n【语言要求】请用评论所使用的语言回复。如果评论是英文,请用英文回复;如果是中文,请用中文回复;如果是日文,请用日文回复;以此类推。\n\n请回复以下评论。注意:\n- 回复长度应与评论长度匹配,简短问候简短回复\n- 不要复述或总结文章内容\n- 自然对话,不要写小作文\n- 只有评论涉及具体内容时才针对性回应\n\n文章(仅供理解上下文,不要复述):\n{{article}}\n\n评论:\n{{comment}}"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,27 @@
|
||||
# Refer https://docs.halo.run/developer-guide/plugin/basics/manifest
|
||||
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
# The name defines how the plugin is invoked, A unique name
|
||||
name: comment-ai-autopilot
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.23.0"
|
||||
author:
|
||||
name: 暖心向阳335
|
||||
website: https://github.com/暖心向阳335
|
||||
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
|
||||
displayName: "AI回评"
|
||||
description: "基于 AI 的 Halo 博客评论自动回复插件,支持 AI 虚拟角色回复、自审核、自动发布和对话式连续回复"
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
url: "https://github.com/暖心向阳335/comment-ai-autopilot/blob/main/LICENSE"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "0.0.1-w5s2t7"
|
||||
pluginDependencies:
|
||||
ai-foundation?: "*"
|
||||
Reference in New Issue
Block a user