refactor: 返璞归真 - 剥离 Markdown 注入,利用 Halo 原生层级回复
- 后端 CommentReplyPublisher 删除 buildQuoteMarkdown 和 Markdown 拼接逻辑 - AI 回复直接存入纯净文本,由 Halo 原生 quoteReply 字段渲染前台层级关系 - 前端 ConversationMessage 恢复 quoteOwner/quoteContent 字段 - 前端对话弹窗添加原生 Tailwind 引用摘要框(灰色 border-l-[3px]) - 简化 renderContent,删除 Markdown 引用正则匹配
This commit is contained in:
@@ -81,37 +81,12 @@ public class CommentReplyPublisher {
|
||||
String postName, String quoteReplyName, boolean autoPublish,
|
||||
String personaName) {
|
||||
|
||||
// 1. 获取被回复的对象信息,构建 Markdown 引用
|
||||
Mono<String> quoteMarkdownMono = Mono.empty();
|
||||
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
// 如果是回复另一条回复
|
||||
quoteMarkdownMono = client.fetch(Reply.class, quoteReplyName)
|
||||
.map(r -> buildQuoteMarkdown(
|
||||
r.getSpec().getOwner() != null ? r.getSpec().getOwner().getDisplayName() : "匿名用户",
|
||||
r.getSpec().getRaw() != null ? r.getSpec().getRaw() : r.getSpec().getContent()
|
||||
));
|
||||
} else if (parentCommentName != null && !parentCommentName.isBlank()) {
|
||||
// 如果是直接回复顶级评论
|
||||
quoteMarkdownMono = client.fetch(Comment.class, parentCommentName)
|
||||
.map(c -> buildQuoteMarkdown(
|
||||
c.getSpec().getOwner() != null ? c.getSpec().getOwner().getDisplayName() : "匿名用户",
|
||||
c.getSpec().getRaw() != null ? c.getSpec().getRaw() : c.getSpec().getContent()
|
||||
));
|
||||
}
|
||||
|
||||
// 2. 解析 AI 角色并合并最终文本进行发布
|
||||
return Mono.zip(resolvePersona(personaName), quoteMarkdownMono.defaultIfEmpty(""))
|
||||
.flatMap(tuple -> {
|
||||
ResolvedPersona persona = tuple.getT1();
|
||||
String quoteMarkdown = tuple.getT2();
|
||||
|
||||
// 解析 AI 角色并直接发布纯净的回复内容
|
||||
return resolvePersona(personaName)
|
||||
.flatMap(persona -> {
|
||||
String displayName = persona.displayName();
|
||||
String email = persona.email();
|
||||
|
||||
// 将引用文本拼接到 AI 回复内容的最前面
|
||||
String finalContent = quoteMarkdown.isBlank() ? replyContent : quoteMarkdown + "\n\n" + replyContent;
|
||||
|
||||
Reply reply = new Reply();
|
||||
reply.setMetadata(new Metadata());
|
||||
reply.getMetadata().setName(generateReplyName());
|
||||
@@ -119,8 +94,11 @@ public class CommentReplyPublisher {
|
||||
|
||||
var spec = reply.getSpec();
|
||||
spec.setCommentName(parentCommentName);
|
||||
spec.setRaw(finalContent); // 保存带有引用的完整 Markdown
|
||||
spec.setContent(finalContent); // 同上
|
||||
|
||||
// 直接存入纯净的 AI 回复内容,不加任何 Markdown 前缀
|
||||
spec.setRaw(replyContent);
|
||||
spec.setContent(replyContent);
|
||||
|
||||
spec.setApproved(autoPublish);
|
||||
if (autoPublish) {
|
||||
spec.setApprovedTime(Instant.now());
|
||||
@@ -130,6 +108,7 @@ public class CommentReplyPublisher {
|
||||
spec.setAllowNotification(false);
|
||||
spec.setHidden(false);
|
||||
|
||||
// Halo 原生评论组件正是靠这个字段来渲染 "回复 @某人" 的
|
||||
if (quoteReplyName != null && !quoteReplyName.isBlank()) {
|
||||
spec.setQuoteReply(quoteReplyName);
|
||||
}
|
||||
@@ -152,7 +131,7 @@ public class CommentReplyPublisher {
|
||||
owner.setAnnotations(ownerAnnotations);
|
||||
spec.setOwner(owner);
|
||||
|
||||
log.info("[Publisher] Creating reply for comment: {}, finalContent length: {}", parentCommentName, finalContent.length());
|
||||
log.info("[Publisher] Creating reply for comment: {}, content length: {}", parentCommentName, replyContent.length());
|
||||
|
||||
return client.create(reply)
|
||||
.doOnSuccess(created -> log.info("[Publisher] AI Persona '{}' reply published for comment: {}", displayName, parentCommentName))
|
||||
@@ -160,19 +139,6 @@ public class CommentReplyPublisher {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增辅助方法:构建 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve persona info from AiPersona extension.
|
||||
* Priority:
|
||||
|
||||
+22
-16
@@ -134,7 +134,17 @@
|
||||
<div class="text-xs mb-1.5 px-1 font-medium" :class="msg.isAi ? 'text-blue-600' : 'text-gray-500 text-right'">
|
||||
{{ msg.owner }}
|
||||
</div>
|
||||
<div class="rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm break-words" :class="msg.isAi ? 'bg-white border border-blue-100 text-gray-800 rounded-tl-sm' : 'bg-blue-600 text-white rounded-tr-sm'" v-html="renderContent(msg.content, msg.isAi)"></div>
|
||||
<div class="rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm break-words" :class="msg.isAi ? 'bg-white border border-blue-100 text-gray-800 rounded-tl-sm' : 'bg-blue-600 text-white rounded-tr-sm'">
|
||||
<!-- 引用摘要模块(如果该条消息有回复对象,则渲染) -->
|
||||
<div v-if="msg.quoteOwner && msg.quoteContent"
|
||||
class="mb-2.5 px-3 py-2 rounded-lg border-l-[3px] text-xs"
|
||||
:class="msg.isAi ? 'bg-gray-50 border-gray-300 text-gray-500' : 'bg-black/10 border-white/40 text-blue-100'">
|
||||
<span class="font-semibold" :class="msg.isAi ? 'text-gray-700' : 'text-white'">@{{ msg.quoteOwner }}</span>:
|
||||
{{ truncateQuote(msg.quoteContent, 40) }}
|
||||
</div>
|
||||
<!-- 实际发言内容 -->
|
||||
<div v-html="renderContent(msg.content)"></div>
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-400 mt-1.5 px-1" :class="msg.isAi ? 'text-left' : 'text-right'">
|
||||
{{ formatDate(msg.time) }}
|
||||
</div>
|
||||
@@ -169,6 +179,8 @@ interface ConversationMessage {
|
||||
content: string
|
||||
time: string
|
||||
isAi: boolean
|
||||
quoteOwner?: string
|
||||
quoteContent?: string
|
||||
}
|
||||
|
||||
const replies = ref<AiCommentReplyItem[]>([])
|
||||
@@ -269,24 +281,18 @@ const stripHtml = (html: string) => {
|
||||
return html.replace(/<[^>]+>/g, "").replace(/\n+/g, " ").trim()
|
||||
}
|
||||
|
||||
const renderContent = (content: string, isAi: boolean) => {
|
||||
const truncateQuote = (content: string, length = 40) => {
|
||||
if (!content) return ""
|
||||
const plain = stripHtml(content)
|
||||
return plain.length > length ? plain.substring(0, length) + "..." : plain
|
||||
}
|
||||
|
||||
const renderContent = (content: string) => {
|
||||
if (!content) return "<span class='opacity-50'>(空)</span>"
|
||||
let parsed = content
|
||||
return content
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<iframe[^>]*>[\s\S]*?<\/iframe>/gi, "")
|
||||
|
||||
parsed = parsed.replace(/^>\s*(?:💬\s*)?\*\*(.*?)\*\*\s*[::]\s*(.*?)$/gm, (match, name, text) => {
|
||||
const quoteBg = isAi ? 'bg-blue-50 border-blue-200' : 'bg-white/20 border-white/30'
|
||||
const nameColor = isAi ? 'text-blue-700' : 'text-white font-semibold'
|
||||
const textColor = isAi ? 'text-gray-600' : 'text-blue-100'
|
||||
return `<div class="mb-3 px-3 py-2 rounded-lg border-l-4 ${quoteBg} text-xs">
|
||||
<span class="${nameColor}">@${name.replace(/💬|🤖/g, '').trim()}</span>:
|
||||
<span class="${textColor} opacity-90">${text.trim()}</span>
|
||||
</div>`
|
||||
})
|
||||
|
||||
parsed = parsed.replace(/\n/g, "<br/>")
|
||||
return parsed
|
||||
.replace(/\n/g, "<br/>")
|
||||
}
|
||||
|
||||
const resetFilters = () => { filterStatus.value = ""; filterSentiment.value = ""; filterKeyword.value = ""; page.value = 1; fetchReplies() }
|
||||
|
||||
Reference in New Issue
Block a user