feat: Markdown 引用注入法 - AI回复自动拼接引用块(主题无关通用方案)

- 后端 CommentReplyPublisher.doPublish 重写,发布前查询被回复对象并拼接 Markdown Blockquote
- 新增 buildQuoteMarkdown 辅助方法,Jsoup 清除 HTML 后截断 40 字符生成引用
- 前端 LogsView 恢复简洁气泡模板,移除 quoteOwner/quoteContent 前端引用逻辑
- renderContent 新增换行符处理,确保 Markdown 引用块正确渲染
This commit is contained in:
bbb-lsy07
2026-06-21 10:16:05 +08:00
parent 0477f532bb
commit 058d562e7e
2 changed files with 88 additions and 74 deletions
@@ -80,63 +80,97 @@ public class CommentReplyPublisher {
private Mono<Reply> doPublish(String parentCommentName, String replyContent, private Mono<Reply> doPublish(String parentCommentName, String replyContent,
String postName, String quoteReplyName, boolean autoPublish, String postName, String quoteReplyName, boolean autoPublish,
String personaName) { String personaName) {
return resolvePersona(personaName).flatMap(persona -> {
String displayName = persona.displayName();
String email = persona.email();
Reply reply = new Reply(); // 1. 获取被回复的对象信息,构建 Markdown 引用
reply.setMetadata(new Metadata()); Mono<String> quoteMarkdownMono = Mono.empty();
reply.getMetadata().setName(generateReplyName());
reply.setSpec(new Reply.ReplySpec());
var spec = reply.getSpec(); if (quoteReplyName != null && !quoteReplyName.isBlank()) {
spec.setCommentName(parentCommentName); // 如果是回复另一条回复
spec.setRaw(replyContent); quoteMarkdownMono = client.fetch(Reply.class, quoteReplyName)
spec.setContent(replyContent); .map(r -> buildQuoteMarkdown(
spec.setApproved(autoPublish); r.getSpec().getOwner() != null ? r.getSpec().getOwner().getDisplayName() : "匿名用户",
if (autoPublish) { r.getSpec().getRaw() != null ? r.getSpec().getRaw() : r.getSpec().getContent()
spec.setApprovedTime(Instant.now()); ));
} } else if (parentCommentName != null && !parentCommentName.isBlank()) {
spec.setPriority(0); // 如果是直接回复顶级评论
spec.setTop(false); quoteMarkdownMono = client.fetch(Comment.class, parentCommentName)
spec.setAllowNotification(false); .map(c -> buildQuoteMarkdown(
spec.setHidden(false); c.getSpec().getOwner() != null ? c.getSpec().getOwner().getDisplayName() : "匿名用户",
c.getSpec().getRaw() != null ? c.getSpec().getRaw() : c.getSpec().getContent()
));
}
if (quoteReplyName != null && !quoteReplyName.isBlank()) { // 2. 解析 AI 角色并合并最终文本进行发布
spec.setQuoteReply(quoteReplyName); return Mono.zip(resolvePersona(personaName), quoteMarkdownMono.defaultIfEmpty(""))
} .flatMap(tuple -> {
ResolvedPersona persona = tuple.getT1();
String quoteMarkdown = tuple.getT2();
var owner = new Comment.CommentOwner(); String displayName = persona.displayName();
owner.setKind(Comment.CommentOwner.KIND_EMAIL); String email = persona.email();
if (email != null && !email.isBlank()) {
owner.setName(email);
} else {
owner.setName(AI_PERSONA_OWNER_PREFIX + displayName);
}
owner.setDisplayName(displayName + " AI");
Map<String, String> ownerAnnotations = new HashMap<>(); // 将引用文本拼接到 AI 回复内容的最前面
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true"); String finalContent = quoteMarkdown.isBlank() ? replyContent : quoteMarkdown + "\n\n" + replyContent;
// 使用Gravatar邮箱头像
if (email != null && !email.isBlank()) {
String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
}
owner.setAnnotations(ownerAnnotations);
spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, owner: kind={}, name={}, displayName={}, annotations={}", Reply reply = new Reply();
parentCommentName, owner.getKind(), owner.getName(), owner.getDisplayName(), ownerAnnotations); reply.setMetadata(new Metadata());
reply.getMetadata().setName(generateReplyName());
reply.setSpec(new Reply.ReplySpec());
return client.create(reply) var spec = reply.getSpec();
.doOnSuccess(created -> { spec.setCommentName(parentCommentName);
var createdOwner = created.getSpec().getOwner(); spec.setRaw(finalContent); // 保存带有引用的完整 Markdown
log.info("[Publisher] AI Persona '{}' reply published for comment: {}, quoteReply: {}, owner annotations after create: {}", spec.setContent(finalContent); // 同上
displayName, parentCommentName, quoteReplyName, spec.setApproved(autoPublish);
createdOwner != null ? createdOwner.getAnnotations() : "null"); if (autoPublish) {
}) spec.setApprovedTime(Instant.now());
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage())); }
}); 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 + displayName);
}
owner.setDisplayName(displayName + " AI");
Map<String, String> ownerAnnotations = new HashMap<>();
ownerAnnotations.put("comment-ai-autopilot.nxxy335.top/is-ai", "true");
if (email != null && !email.isBlank()) {
String gravatarUrl = GravatarUtil.generateUrl(email);
ownerAnnotations.put(Comment.CommentOwner.AVATAR_ANNO, gravatarUrl);
}
owner.setAnnotations(ownerAnnotations);
spec.setOwner(owner);
log.info("[Publisher] Creating reply for comment: {}, finalContent length: {}", parentCommentName, finalContent.length());
return client.create(reply)
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}", displayName, parentCommentName))
.doOnError(e -> log.error("[Publisher] Failed to publish AI reply: {}", e.getMessage()));
});
}
/**
* 新增辅助方法:构建 Markdown 引用块
*/
private String buildQuoteMarkdown(String username, String rawText) {
if (rawText == null || rawText.isBlank()) return "";
// 清除 HTML 标签,防止破坏 Markdown 结构
String plainText = org.jsoup.Jsoup.clean(rawText, org.jsoup.safety.Safelist.none()).trim();
// 截断过长的引用内容(超过 40 个字符加省略号)
String truncated = plainText.length() > 40 ? plainText.substring(0, 40) + "..." : plainText;
// 生成标准 Markdown Blockquote
return "> 💬 **@" + username + "** : " + truncated;
} }
/** /**
+4 -24
View File
@@ -299,19 +299,8 @@
:class="msg.isAi :class="msg.isAi
? 'bg-blue-50 text-gray-800 rounded-tl-md' ? 'bg-blue-50 text-gray-800 rounded-tl-md'
: 'bg-gray-100 text-gray-800 rounded-tr-md'" : 'bg-gray-100 text-gray-800 rounded-tr-md'"
> v-html="renderContent(msg.content)"
<!-- 引用摘要模块仅当存在引用时显示 --> ></div>
<div
v-if="msg.quoteOwner && msg.quoteContent"
class="mb-2 px-3 py-1.5 bg-black/5 rounded-lg border-l-2 border-gray-300 text-xs text-gray-500"
>
<span class="font-medium text-gray-600">@{{ msg.quoteOwner }}</span>:
{{ truncateQuote(msg.quoteContent, 30) }}
</div>
<!-- 实际回复内容 -->
<div v-html="renderContent(msg.content)"></div>
</div>
<!-- Time --> <!-- Time -->
<div class="text-[10px] text-gray-300 mt-1" :class="msg.isAi ? 'text-left' : 'text-right'"> <div class="text-[10px] text-gray-300 mt-1" :class="msg.isAi ? 'text-left' : 'text-right'">
{{ formatDate(msg.time) }} {{ formatDate(msg.time) }}
@@ -369,8 +358,6 @@ interface ConversationMessage {
content: string content: string
time: string time: string
isAi: boolean isAi: boolean
quoteOwner?: string
quoteContent?: string
} }
const replies = ref<AiCommentReplyItem[]>([]) const replies = ref<AiCommentReplyItem[]>([])
@@ -652,15 +639,6 @@ const stripHtml = (html: string) => {
.trim() .trim()
} }
/**
* Truncate quote content for preview
*/
const truncateQuote = (content: string, length = 30) => {
if (!content) return ""
const plain = stripHtml(content)
return plain.length > length ? plain.substring(0, length) + "..." : plain
}
/** /**
* Sanitize and render HTML content for conversation bubbles. * Sanitize and render HTML content for conversation bubbles.
* Only allows safe inline tags, strips dangerous elements. * Only allows safe inline tags, strips dangerous elements.
@@ -668,6 +646,8 @@ const truncateQuote = (content: string, length = 30) => {
const renderContent = (content: string) => { const renderContent = (content: string) => {
if (!content) return "<span class='text-gray-400'>(空)</span>" if (!content) return "<span class='text-gray-400'>(空)</span>"
return content return content
// 将 Markdown 的换行符替换为 HTML 的换行
.replace(/\n/g, "<br/>")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "") .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "") .replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
.replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "") .replace(/<object[^>]*>[\s\S]*?<\/object>/gi, "")