feat: v1.0.0 - 唤醒词、性别配置、身份强化、移动端适配、Bug修复
This commit is contained in:
@@ -67,6 +67,9 @@ public class CommentAiAutopilotPlugin extends BasePlugin {
|
||||
spec.setDisplayName("小回");
|
||||
spec.setPrompt("你是一个友善的评论者,回复简洁自然,像朋友聊天一样。");
|
||||
spec.setEmail("");
|
||||
spec.setGender("female");
|
||||
spec.setNeutralVoice(true);
|
||||
spec.setWakeWord("小回小回");
|
||||
spec.setIsDefault(true);
|
||||
persona.setSpec(spec);
|
||||
return client.create(persona);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.nxxy335.commentaiautopilot;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class ObjectMapperConfiguration {
|
||||
|
||||
@Bean
|
||||
ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -643,7 +643,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
// Read persona name from post annotations
|
||||
return personaResolver.getPersonaNameFromComment(commentName)
|
||||
.flatMap(personaName ->
|
||||
orchestrator.processComment(commentName, null, false, personaName)
|
||||
orchestrator.processComment(commentName, null, false, personaName, false)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI回复")))
|
||||
);
|
||||
});
|
||||
@@ -670,7 +670,7 @@ public class CommentAiAutopilotEndpoint implements CustomEndpoint {
|
||||
}
|
||||
return personaResolver.getPersonaNameFromComment(commentName)
|
||||
.flatMap(personaName ->
|
||||
orchestrator.processComment(commentName, replyName, true, personaName)
|
||||
orchestrator.processComment(commentName, replyName, true, personaName, false)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "已触发AI对话回复")))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,16 @@ public class AiPersona extends AbstractExtension {
|
||||
@Schema(description = "邮箱(用于Gravatar头像)")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "角色性别(male/female)")
|
||||
private String gender;
|
||||
|
||||
@Schema(description = "是否使用中性语气,默认false即跟随性别语气")
|
||||
@JsonProperty("neutralVoice")
|
||||
private Boolean neutralVoice;
|
||||
|
||||
@Schema(description = "唤醒词,评论以此开头则唤醒该角色回复,留空则不启用唤醒")
|
||||
private String wakeWord;
|
||||
|
||||
@Schema(description = "是否为默认角色")
|
||||
@JsonProperty("isDefault")
|
||||
private Boolean isDefault;
|
||||
|
||||
@@ -12,6 +12,7 @@ import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||
import top.nxxy335.commentaiautopilot.service.WakeWordService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
@@ -25,6 +26,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final PersonaResolver personaResolver;
|
||||
private final WakeWordService wakeWordService;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
@@ -76,18 +78,38 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
markProcessed(comment);
|
||||
client.update(comment);
|
||||
|
||||
// Read persona name from the post's annotations
|
||||
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
|
||||
// Check for wake word in comment content
|
||||
String commentContent = getCommentContent(comment);
|
||||
log.info("[CommentReconciler] Wake word check for comment {}: content='{}'",
|
||||
name, commentContent.length() > 80 ? commentContent.substring(0, 80) + "..." : commentContent);
|
||||
var wakeMatch = wakeWordService.checkWakeWordBlocking(client, commentContent);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||
orchestrator.processComment(name, null, false, personaName)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||
);
|
||||
if (wakeMatch != null) {
|
||||
// Wake word matched: trigger AI reply with the matched persona,
|
||||
// bypassing normal page-level enable check
|
||||
log.info("[CommentReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
|
||||
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
|
||||
orchestrator.processComment(name, null, false, wakeMatch.personaName(), true)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing wake word comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Wake word processing completed for comment: {}", name)
|
||||
);
|
||||
} else {
|
||||
// Normal flow: read persona name from the post's annotations
|
||||
String personaName = personaResolver.getPersonaNameFromCommentBlocking(client, comment);
|
||||
|
||||
// Top-level comment → always trigger AI reply
|
||||
log.info("[CommentReconciler] New top-level comment detected: {}, personaName: {}", name, personaName);
|
||||
orchestrator.processComment(name, null, false, personaName, false)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[CommentReconciler] Error processing comment {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[CommentReconciler] Processing completed for comment: {}", name)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Result.doNotRetry();
|
||||
@@ -122,6 +144,22 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
private String getCommentContent(Comment comment) {
|
||||
if (comment.getSpec() == null) return "";
|
||||
// Always strip HTML to get plain text for wake word matching
|
||||
String raw = comment.getSpec().getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
// raw might still contain HTML in some cases, always strip
|
||||
String plain = org.jsoup.Jsoup.clean(raw, org.jsoup.safety.Safelist.none()).trim();
|
||||
if (!plain.isBlank()) return plain;
|
||||
}
|
||||
String content = comment.getSpec().getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return org.jsoup.Jsoup.clean(content, org.jsoup.safety.Safelist.none()).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
|
||||
@@ -13,6 +13,7 @@ import run.halo.app.extension.controller.Reconciler;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiCommentReply;
|
||||
import top.nxxy335.commentaiautopilot.service.AiReplyOrchestrator;
|
||||
import top.nxxy335.commentaiautopilot.service.PersonaResolver;
|
||||
import top.nxxy335.commentaiautopilot.service.WakeWordService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
@@ -26,6 +27,7 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
private final ExtensionClient client;
|
||||
private final AiReplyOrchestrator orchestrator;
|
||||
private final PersonaResolver personaResolver;
|
||||
private final WakeWordService wakeWordService;
|
||||
|
||||
private static final String PROCESSED_ANNOTATION = "comment-ai-autopilot.nxxy335.top/processed";
|
||||
private static final String AI_PERSONA_OWNER_PREFIX = "ai-persona-";
|
||||
@@ -75,25 +77,21 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
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);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
}
|
||||
// Check for wake word FIRST - wake word can bypass "must be reply to AI" check
|
||||
String replyContent = getReplyContent(reply);
|
||||
log.info("[ReplyReconciler] Wake word check for reply {}: content='{}'",
|
||||
name, replyContent.length() > 80 ? replyContent.substring(0, 80) + "..." : replyContent);
|
||||
var wakeMatch = wakeWordService.checkWakeWordBlocking(client, replyContent);
|
||||
|
||||
// This reply quotes another reply - check if the quoted reply is from AI
|
||||
boolean isReplyToAi = isAiReply(quoteReply);
|
||||
String quoteReply = reply.getSpec().getQuoteReply();
|
||||
boolean isReplyToAi = quoteReply != null && !quoteReply.isBlank() && isAiReply(quoteReply);
|
||||
log.debug("[ReplyReconciler] Reply {} quotes {}, isAiReply={}", name, quoteReply, isReplyToAi);
|
||||
|
||||
if (!isReplyToAi) {
|
||||
log.debug("[ReplyReconciler] Not a reply to AI, skipping: {}", name);
|
||||
// Skip if not a reply to AI AND no wake word matched
|
||||
if (!isReplyToAi && wakeMatch == null) {
|
||||
// No quoteReply or not replying to AI, and no wake word - skip
|
||||
log.debug("[ReplyReconciler] Not a reply to AI and no wake word, skipping: {}", name);
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
return;
|
||||
@@ -117,18 +115,32 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
markProcessed(reply);
|
||||
client.update(reply);
|
||||
|
||||
// Reply to AI → trigger AI reply (conversation continuation)
|
||||
String personaName = client.fetch(Comment.class, parentCommentName)
|
||||
.map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment))
|
||||
.orElse(null);
|
||||
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
||||
orchestrator.processComment(parentCommentName, name, true, personaName)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[ReplyReconciler] Error processing reply {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[ReplyReconciler] Processing completed for reply: {}", name)
|
||||
);
|
||||
if (wakeMatch != null) {
|
||||
// Wake word matched: trigger AI reply with the matched persona,
|
||||
// bypassing the "must be reply to AI" check and page-level enable check
|
||||
log.info("[ReplyReconciler] Wake word '{}' matched for persona '{}', triggering reply for: {}",
|
||||
wakeMatch.wakeWord(), wakeMatch.personaName(), name);
|
||||
orchestrator.processComment(parentCommentName, name, true, wakeMatch.personaName(), true)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.subscribe(
|
||||
null,
|
||||
e -> log.error("[ReplyReconciler] Error processing wake word reply {}: {}", name, e.getMessage(), e),
|
||||
() -> log.info("[ReplyReconciler] Wake word processing completed for reply: {}", name)
|
||||
);
|
||||
} else if (isReplyToAi) {
|
||||
// Normal flow: reply to AI → trigger AI reply (conversation continuation)
|
||||
String personaName = client.fetch(Comment.class, parentCommentName)
|
||||
.map(comment -> personaResolver.getPersonaNameFromCommentBlocking(client, comment))
|
||||
.orElse(null);
|
||||
log.info("[ReplyReconciler] Reply to AI detected: {}, triggering conversation, personaName: {}", name, personaName);
|
||||
orchestrator.processComment(parentCommentName, name, true, personaName, false)
|
||||
.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();
|
||||
@@ -167,6 +179,22 @@ public class ReplyReconciler implements Reconciler<Reconciler.Request> {
|
||||
annotations.put(PROCESSED_ANNOTATION, "true");
|
||||
}
|
||||
|
||||
private String getReplyContent(Reply reply) {
|
||||
if (reply.getSpec() == null) return "";
|
||||
// Always strip HTML to get plain text for wake word matching
|
||||
String raw = reply.getSpec().getRaw();
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
// raw might still contain HTML in some cases, always strip
|
||||
String plain = org.jsoup.Jsoup.clean(raw, org.jsoup.safety.Safelist.none()).trim();
|
||||
if (!plain.isBlank()) return plain;
|
||||
}
|
||||
String content = reply.getSpec().getContent();
|
||||
if (content != null && !content.isBlank()) {
|
||||
return org.jsoup.Jsoup.clean(content, org.jsoup.safety.Safelist.none()).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
|
||||
@@ -70,9 +70,10 @@ public class AiReplyOrchestrator {
|
||||
* @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)
|
||||
* @param wakeWordTriggered true when triggered by a wake word (bypasses page-level enable check)
|
||||
*/
|
||||
public Mono<Void> processComment(String commentName, String replyName, boolean isAiConversation,
|
||||
String personaName) {
|
||||
String personaName, boolean wakeWordTriggered) {
|
||||
String lockKey = isAiConversation ? commentName + ":conv:" + replyName : commentName + ":top";
|
||||
|
||||
// Clean up stale locks before acquiring new one
|
||||
@@ -84,12 +85,12 @@ public class AiReplyOrchestrator {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}",
|
||||
commentName, replyName, isAiConversation, personaName);
|
||||
log.info("[Orchestrator] Start processing: comment={}, replyName={}, isAiConversation={}, personaName={}, wakeWordTriggered={}",
|
||||
commentName, replyName, isAiConversation, personaName, wakeWordTriggered);
|
||||
|
||||
return isAutoReplyEnabled()
|
||||
.flatMap(enabled -> {
|
||||
if (!enabled) {
|
||||
if (!enabled && !wakeWordTriggered) {
|
||||
log.info("[Orchestrator] Auto reply disabled, skipping: {}", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
@@ -99,42 +100,24 @@ public class AiReplyOrchestrator {
|
||||
log.info("[Orchestrator] 速率限制,跳过: {}", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
// Wake word triggered: skip page-level annotation check
|
||||
if (wakeWordTriggered) {
|
||||
return checkBlockedCommenters(commentName)
|
||||
.flatMap(blocked -> {
|
||||
if (blocked) {
|
||||
log.info("[Orchestrator] Commenter blocked, skipping wake word: {}", commentName);
|
||||
return Mono.empty();
|
||||
}
|
||||
return proceedWithProcess(commentName, replyName, isAiConversation, personaName);
|
||||
});
|
||||
}
|
||||
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, 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, personaName);
|
||||
});
|
||||
})
|
||||
);
|
||||
return proceedWithProcess(commentName, replyName, isAiConversation, personaName);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -147,6 +130,81 @@ public class AiReplyOrchestrator {
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceed with processing after all checks have passed.
|
||||
* Handles dedup checks and conversation round limits.
|
||||
*/
|
||||
private Mono<Void> proceedWithProcess(String commentName, String replyName,
|
||||
boolean isAiConversation, String personaName) {
|
||||
// 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, 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, personaName);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the commenter is in the blocked list.
|
||||
*/
|
||||
private Mono<Boolean> checkBlockedCommenters(String commentName) {
|
||||
return client.fetch(run.halo.app.core.extension.content.Comment.class, commentName)
|
||||
.flatMap(comment -> {
|
||||
var owner = comment.getSpec().getOwner();
|
||||
if (owner == null) return Mono.just(false);
|
||||
String displayName = owner.getDisplayName();
|
||||
String email = run.halo.app.core.extension.content.Comment.CommentOwner.KIND_EMAIL.equals(owner.getKind())
|
||||
? owner.getName() : "";
|
||||
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 blockedStr = node.has("blockedCommenters") ? node.get("blockedCommenters").asText("") : "";
|
||||
if (blockedStr.isBlank()) return false;
|
||||
for (String item : blockedStr.split(",")) {
|
||||
String trimmed = item.trim();
|
||||
if (!trimmed.isEmpty() && (trimmed.equalsIgnoreCase(displayName) || trimmed.equalsIgnoreCase(email))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
private Mono<Void> doProcess(String commentName, String replyName, boolean isAiConversation,
|
||||
String personaName) {
|
||||
return getModelName().flatMap(modelName ->
|
||||
|
||||
@@ -119,6 +119,10 @@ public class ContextExtractor {
|
||||
))
|
||||
)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[ContextExtractor] Failed to fetch Post {}: {}", postName, e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
@@ -139,7 +143,7 @@ public class ContextExtractor {
|
||||
if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(SinglePage.class, postName)
|
||||
.flatMap(singlePage -> getPostContent(postName)
|
||||
.flatMap(singlePage -> getSinglePageContent(postName)
|
||||
.flatMap(content -> getCommentCount(comment.getMetadata().getName())
|
||||
.map(commentCount -> new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
@@ -158,6 +162,10 @@ public class ContextExtractor {
|
||||
))
|
||||
)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[ContextExtractor] Failed to fetch SinglePage {}: {}", postName, e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
comment.getMetadata().getName(),
|
||||
postName,
|
||||
@@ -228,6 +236,10 @@ public class ContextExtractor {
|
||||
)
|
||||
)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[ContextExtractor] Failed to fetch Post {} for reply: {}", postName, e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
commentName,
|
||||
postName,
|
||||
@@ -248,7 +260,7 @@ public class ContextExtractor {
|
||||
if (subjectRef != null && "SinglePage".equals(subjectRef.getKind())) {
|
||||
String postName = subjectRef.getName();
|
||||
return client.fetch(SinglePage.class, postName)
|
||||
.flatMap(singlePage -> getPostContent(postName)
|
||||
.flatMap(singlePage -> getSinglePageContent(postName)
|
||||
.flatMap(content -> getCommentCount(commentName)
|
||||
.flatMap(commentCount -> historyMono
|
||||
.map(history -> new CommentContext(
|
||||
@@ -269,6 +281,10 @@ public class ContextExtractor {
|
||||
)
|
||||
)
|
||||
)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("[ContextExtractor] Failed to fetch SinglePage {} for reply: {}", postName, e.getMessage());
|
||||
return Mono.empty();
|
||||
})
|
||||
.defaultIfEmpty(new CommentContext(
|
||||
commentName,
|
||||
postName,
|
||||
@@ -365,6 +381,23 @@ public class ContextExtractor {
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private Mono<String> getSinglePageContent(String pageName) {
|
||||
// SinglePage doesn't have a dedicated ContentService in Halo API,
|
||||
// and Snapshot content requires patch merging which is too complex.
|
||||
// Use the excerpt from status as a fallback for context.
|
||||
return client.fetch(SinglePage.class, pageName)
|
||||
.mapNotNull(page -> {
|
||||
if (page.getStatus() != null && page.getStatus().getExcerpt() != null) {
|
||||
String excerpt = page.getStatus().getExcerpt();
|
||||
if (excerpt != null && !excerpt.isBlank()) {
|
||||
return excerpt;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.defaultIfEmpty("");
|
||||
}
|
||||
|
||||
private String formatPostDate(Post post) {
|
||||
var publishTime = post.getSpec().getPublishTime();
|
||||
if (publishTime != null) {
|
||||
|
||||
@@ -52,8 +52,9 @@ public class PromptBuilder {
|
||||
private static final String SAFETY_PROMPT = """
|
||||
【安全规范】
|
||||
- 内容红线:坚决不生成任何涉及暴力、歧视、辱骂、人身攻击或违反法律法规的内容。
|
||||
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,作为AI助手,我无法提供此类回复。"
|
||||
- 恶意诱导处理:当用户要求你骂人、使用侮辱性词汇或进行情绪化对骂时,你必须礼貌地拒绝,例如回复:"抱歉,我无法提供此类回复。"
|
||||
- 未知与边界:如果不知道答案或遇到敏感话题,请诚实告知并礼貌拒绝,绝不编造或使用极端言辞。
|
||||
- 身份约束:你必须在回复中保持指定的角色身份,绝不能说自己是AI、没有性别或脱离角色设定。
|
||||
""";
|
||||
|
||||
private static final String DEFAULT_PROMPT_TEMPLATE = """
|
||||
@@ -194,7 +195,10 @@ public class PromptBuilder {
|
||||
return client.fetch(AiPersona.class, personaName)
|
||||
.mapNotNull(persona -> {
|
||||
String prompt = persona.getSpec().getPrompt();
|
||||
return (prompt != null && !prompt.isBlank()) ? prompt : null;
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
return appendStyleHint(prompt, persona.getSpec().getDisplayName(), persona.getSpec().getGender(), persona.getSpec().getNeutralVoice());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||
}
|
||||
@@ -205,11 +209,40 @@ public class PromptBuilder {
|
||||
.next()
|
||||
.mapNotNull(persona -> {
|
||||
String prompt = persona.getSpec().getPrompt();
|
||||
return (prompt != null && !prompt.isBlank()) ? prompt : null;
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
return appendStyleHint(prompt, persona.getSpec().getDisplayName(), persona.getSpec().getGender(), persona.getSpec().getNeutralVoice());
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.defaultIfEmpty(DEFAULT_PERSONA_PROMPT);
|
||||
}
|
||||
|
||||
private String appendStyleHint(String prompt, String displayName, String gender, Boolean neutralVoice) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// 身份信息前置到最开头 - 这是AI最先看到的内容,优先级最高
|
||||
if (gender != null && !gender.isBlank()) {
|
||||
String genderDesc = "female".equals(gender) ? "女生" : "男生";
|
||||
sb.append("【核心身份】你叫「").append(displayName != null ? displayName : "").append("」,你是一个").append(genderDesc).append("。");
|
||||
sb.append("这是你的基本身份,你必须始终以这个身份回复。");
|
||||
sb.append("当有人问你性别时,你必须回答自己是").append(genderDesc).append("。");
|
||||
sb.append("绝对不能说自己是AI、没有性别、或脱离角色。");
|
||||
sb.append("\n\n");
|
||||
}
|
||||
|
||||
sb.append(prompt);
|
||||
|
||||
// 添加语气风格提示
|
||||
if (!Boolean.TRUE.equals(neutralVoice)) {
|
||||
if ("female".equals(gender)) {
|
||||
sb.append("\n请使用温柔、细腻的女性语气风格回复。");
|
||||
} else if ("male".equals(gender)) {
|
||||
sb.append("\n请使用沉稳、理性的男性语气风格回复。");
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private Mono<String> getEnabledPresetsPrompt() {
|
||||
return client.fetch(ConfigMap.class, CONFIG_MAP_NAME)
|
||||
.mapNotNull(cm -> {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package top.nxxy335.commentaiautopilot.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import top.nxxy335.commentaiautopilot.extension.AiPersona;
|
||||
|
||||
/**
|
||||
* Service for checking wake words in comment content.
|
||||
* A wake word is a prefix that triggers AI reply from a specific persona,
|
||||
* even if the page hasn't enabled AI auto-reply.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class WakeWordService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Result of a wake word match.
|
||||
*
|
||||
* @param personaName the metadata.name of the matched persona
|
||||
* @param wakeWord the wake word that matched
|
||||
* @param contentWithoutWakeWord the comment content with the wake word prefix removed
|
||||
*/
|
||||
public record WakeWordMatch(String personaName, String wakeWord, String contentWithoutWakeWord) {}
|
||||
|
||||
/**
|
||||
* Check if the given content starts with any persona's wake word.
|
||||
* Returns the first matching persona's info, or empty if no match.
|
||||
*
|
||||
* @param content the comment/reply content to check
|
||||
* @return WakeWordMatch if a wake word is found, or empty Mono
|
||||
*/
|
||||
public Mono<WakeWordMatch> checkWakeWord(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return client.list(AiPersona.class, null, null)
|
||||
.filter(persona -> {
|
||||
String wakeWord = persona.getSpec().getWakeWord();
|
||||
return wakeWord != null && !wakeWord.isBlank() && content.startsWith(wakeWord);
|
||||
})
|
||||
.next()
|
||||
.map(persona -> {
|
||||
String wakeWord = persona.getSpec().getWakeWord();
|
||||
String remaining = content.substring(wakeWord.length()).trim();
|
||||
log.info("[WakeWord] Matched wake word '{}' for persona '{}'",
|
||||
wakeWord, persona.getSpec().getDisplayName());
|
||||
return new WakeWordMatch(persona.getMetadata().getName(), wakeWord, remaining);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking version for use in Reconciler (sync context).
|
||||
* Checks if the given content starts with any persona's wake word.
|
||||
*
|
||||
* @param syncClient the blocking ExtensionClient
|
||||
* @param content the comment/reply content to check
|
||||
* @return WakeWordMatch if a wake word is found, or null
|
||||
*/
|
||||
public WakeWordMatch checkWakeWordBlocking(run.halo.app.extension.ExtensionClient syncClient, String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
log.info("[WakeWord] Content is null or blank, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmedContent = content.trim();
|
||||
var personas = syncClient.listAll(AiPersona.class, null, Sort.unsorted());
|
||||
log.info("[WakeWord] Checking {} personas against content: '{}'", personas.size(),
|
||||
trimmedContent.length() > 50 ? trimmedContent.substring(0, 50) + "..." : trimmedContent);
|
||||
|
||||
for (var persona : personas) {
|
||||
String wakeWord = persona.getSpec().getWakeWord();
|
||||
if (wakeWord == null || wakeWord.isBlank()) {
|
||||
log.info("[WakeWord] Persona '{}' has no wakeWord, skipping", persona.getSpec().getDisplayName());
|
||||
continue;
|
||||
}
|
||||
String trimmedWakeWord = wakeWord.trim();
|
||||
log.info("[WakeWord] Checking persona '{}' with wakeWord '{}' against content starting with '{}'",
|
||||
persona.getSpec().getDisplayName(), trimmedWakeWord,
|
||||
trimmedContent.length() >= trimmedWakeWord.length()
|
||||
? trimmedContent.substring(0, trimmedWakeWord.length()) : trimmedContent);
|
||||
|
||||
if (trimmedContent.startsWith(trimmedWakeWord)) {
|
||||
String remaining = trimmedContent.substring(trimmedWakeWord.length()).trim();
|
||||
log.info("[WakeWord] MATCHED! wakeWord='{}' for persona '{}', remaining content: '{}'",
|
||||
trimmedWakeWord, persona.getSpec().getDisplayName(),
|
||||
remaining.length() > 30 ? remaining.substring(0, 30) + "..." : remaining);
|
||||
return new WakeWordMatch(persona.getMetadata().getName(), trimmedWakeWord, remaining);
|
||||
}
|
||||
}
|
||||
log.info("[WakeWord] No wake word matched");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -30,4 +30,4 @@ spec:
|
||||
url: "https://github.com/sunny-335/plugin-comment-ai-autopilot/blob/main/LICENSE"
|
||||
settingName: "comment-ai-autopilot-settings"
|
||||
configMapName: "comment-ai-autopilot-configmap"
|
||||
version: "1.0.0-b26cea"
|
||||
version: "1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user